diff --git a/apps/web/package.json b/apps/web/package.json index 03f77c3aba8..8982fb02cff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "decode-named-character-reference": "^1.3.0", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 00000000000..a4e36f58b19 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); + +describe("ChatMarkdown", () => { + it("highlights assistant markdown text matches", async () => { + const { default: ChatMarkdown } = await import("./ChatMarkdown"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlight<"); + }); + + it("highlights fenced code matches without dropping the visible mark", async () => { + const { default: ChatMarkdown } = await import("./ChatMarkdown"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlightNeedle<"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6a85ccee96a..015295f91d9 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -29,7 +29,15 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { - normalizeMarkdownLinkDestination, + createThreadSearchHighlightRehypePlugin, + renderHighlightedText, + textContainsThreadSearchMatch, +} from "./chat/threadSearchHighlight"; +import { + buildFileLinkParentSuffixByPath, + buildMarkdownFileLinkLabel, + extractMarkdownLinkHrefs, + normalizeMarkdownLinkHrefKey, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, } from "../markdown-links"; @@ -62,6 +70,8 @@ interface ChatMarkdownProps { cwd: string | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; + searchQuery?: string; + searchActive?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -286,87 +296,11 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; -function pathParentSegments(path: string): string[] { - const normalized = path.replaceAll("\\", "/"); - const segments = normalized.split("/").filter((segment) => segment.length > 0); - return segments.slice(0, -1); -} - -function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { - const groups = new Map>(); - for (const filePath of filePaths) { - const pathSegments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); - const basename = pathSegments[pathSegments.length - 1]; - if (!basename) continue; - const group = groups.get(basename) ?? new Set(); - group.add(filePath); - groups.set(basename, group); - } - - const suffixByPath = new Map(); - for (const group of groups.values()) { - const uniquePaths = [...group]; - if (uniquePaths.length < 2) continue; - - const parentSegmentsByPath = new Map( - uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), - ); - const minUniqueDepthByPath = new Map(); - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - let resolvedDepth = segments.length; - for (let depth = 1; depth <= segments.length; depth += 1) { - const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { - resolvedDepth = depth; - break; - } - } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); - suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); - } - } - - return suffixByPath; -} - -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} - -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; -} - const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -517,9 +451,15 @@ function ChatMarkdown({ cwd, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, + searchQuery = "", + searchActive = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const searchHighlightPlugin = useMemo( + () => createThreadSearchHighlightRehypePlugin(searchQuery, { active: searchActive }), + [searchActive, searchQuery], + ); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, @@ -558,15 +498,6 @@ function ChatMarkdown({ } const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); - } return ( @@ -585,6 +516,24 @@ function ChatMarkdown({ if (!codeBlock) { return
{children}
; } + if (textContainsThreadSearchMatch(codeBlock.code, searchQuery)) { + return ( + +
+                
+                  {renderHighlightedText(
+                    codeBlock.code,
+                    searchQuery,
+                    `markdown-code:${codeBlock.code}`,
+                    {
+                      active: searchActive,
+                    },
+                  )}
+                
+              
+
+ ); + } return ( @@ -608,6 +557,8 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, resolvedTheme, + searchActive, + searchQuery, skills, ], ); @@ -616,6 +567,7 @@ function ChatMarkdown({
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..d75234d95e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -35,7 +35,7 @@ import { import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -146,6 +146,15 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { ThreadSearchBar } from "./chat/ThreadSearchBar"; +import { deriveMessagesTimelineRows, type TimelineRow } from "./chat/MessagesTimeline.logic"; +import { + buildThreadSearchIndex, + createEmptyThreadSearchLookupState, + findThreadSearchLookupState, + type ThreadSearchIndexEntry, + type ThreadSearchLookupState, +} from "./chat/threadSearch"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -200,6 +209,9 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_THREAD_SEARCH_INDEX: readonly ThreadSearchIndexEntry[] = []; +const EMPTY_THREAD_SEARCH_ROWS: readonly TimelineRow[] = []; +const EMPTY_MATCHED_THREAD_SEARCH_ROW_IDS = new Set(); type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -208,6 +220,26 @@ type EnvironmentUnavailableState = { type ThreadPlanCatalogEntry = Pick; +function isThreadSearchShortcut(event: KeyboardEvent, platform = navigator.platform): boolean { + const key = event.key.toLowerCase(); + const primaryModifier = platform.toLowerCase().includes("mac") ? event.metaKey : event.ctrlKey; + return primaryModifier && !event.altKey && !event.shiftKey && key === "f"; +} + +function isThreadSearchInputTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + const tagName = target.tagName.toLowerCase(); + return ( + tagName === "input" || + tagName === "textarea" || + tagName === "select" || + target.isContentEditable + ); +} + function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { return useStore( useMemo(() => { @@ -686,6 +718,9 @@ export default function ChatView(props: ChatViewProps) { const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); + const [threadSearchOpen, setThreadSearchOpen] = useState(false); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const [activeThreadSearchResultIndex, setActiveThreadSearchResultIndex] = useState(-1); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; @@ -728,6 +763,8 @@ export default function ChatView(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const threadSearchInputRef = useRef(null); + const threadSearchRestoreFocusRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -1624,15 +1661,15 @@ export default function ChatView(props: ChatViewProps) { if (!completionSummary) return null; return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + const activeProjectCwd = activeProject?.cwd ?? null; + const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, worktreePath: activeThread?.worktreePath ?? null, }) : null; - const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); - const keybindings = useServerKeybindings(); - const availableEditors = useServerAvailableEditors(); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -1651,9 +1688,90 @@ export default function ChatView(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); - const activeProjectCwd = activeProject?.cwd ?? null; - const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + const threadSearchRows = useMemo(() => { + if (!threadSearchOpen) return EMPTY_THREAD_SEARCH_ROWS; + return deriveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + isWorking, + activeTurnInProgress: isWorking || !latestTurnSettled, + activeTurnId: activeLatestTurn?.turnId ?? null, + activeTurnStartedAt: activeWorkStartedAt, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId, + }); + }, [ + activeLatestTurn?.turnId, + activeWorkStartedAt, + completionDividerBeforeEntryId, + completionSummary, + isWorking, + latestTurnSettled, + revertTurnCountByUserMessageId, + threadSearchOpen, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); + const deferredThreadSearchQuery = useDeferredValue(threadSearchQuery); + const threadSearchIndex = useMemo( + () => + threadSearchOpen + ? buildThreadSearchIndex(threadSearchRows, { + markdownCwd: gitCwd ?? undefined, + skills: activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS, + workspaceRoot: activeWorkspaceRoot, + }) + : EMPTY_THREAD_SEARCH_INDEX, + [activeProviderStatus?.skills, activeWorkspaceRoot, gitCwd, threadSearchOpen, threadSearchRows], + ); + const threadSearchLookupStateRef = useRef( + createEmptyThreadSearchLookupState(threadSearchIndex), + ); + const threadSearchLookupState = useMemo( + () => + findThreadSearchLookupState( + threadSearchIndex, + threadSearchOpen ? deferredThreadSearchQuery : "", + threadSearchLookupStateRef.current, + ), + [deferredThreadSearchQuery, threadSearchIndex, threadSearchOpen], + ); + useEffect(() => { + threadSearchLookupStateRef.current = threadSearchLookupState; + }, [threadSearchLookupState]); + const threadSearchResults = threadSearchLookupState.results; + const matchedThreadSearchRowIds = useMemo( + () => + threadSearchResults.length > 0 + ? new Set(threadSearchResults.map((result) => result.rowId)) + : EMPTY_MATCHED_THREAD_SEARCH_ROW_IDS, + [threadSearchResults], + ); + const activeThreadSearchRowId = + threadSearchOpen && activeThreadSearchResultIndex >= 0 + ? (threadSearchResults[activeThreadSearchResultIndex]?.rowId ?? null) + : null; + + useEffect(() => { + const normalizedQuery = deferredThreadSearchQuery.trim(); + setActiveThreadSearchResultIndex(normalizedQuery.length > 0 ? 0 : -1); + }, [deferredThreadSearchQuery]); + + useEffect(() => { + setActiveThreadSearchResultIndex((current) => { + if (threadSearchResults.length === 0) { + return -1; + } + if (current < 0) { + return 0; + } + return Math.min(current, threadSearchResults.length - 1); + }); + }, [threadSearchResults]); + const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); + const keybindings = useServerKeybindings(); + const availableEditors = useServerAvailableEditors(); const activeTerminalLaunchContext = terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext @@ -2207,6 +2325,60 @@ export default function ChatView(props: ChatViewProps) { legendListRef.current?.scrollToEnd?.({ animated }); }, []); + const focusThreadSearchInput = useCallback((select = false) => { + window.requestAnimationFrame(() => { + const input = threadSearchInputRef.current; + if (!input) { + return; + } + input.focus(); + if (select) { + input.select(); + } + }); + }, []); + + const openThreadSearch = useCallback( + (select = false) => { + const activeElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + if (activeElement && activeElement !== threadSearchInputRef.current) { + threadSearchRestoreFocusRef.current = activeElement; + } + setThreadSearchOpen(true); + focusThreadSearchInput(select); + }, + [focusThreadSearchInput], + ); + + const closeThreadSearch = useCallback(() => { + setThreadSearchOpen(false); + setThreadSearchQuery(""); + setActiveThreadSearchResultIndex(-1); + const focusTarget = threadSearchRestoreFocusRef.current; + threadSearchRestoreFocusRef.current = null; + if (focusTarget && document.contains(focusTarget)) { + window.requestAnimationFrame(() => { + focusTarget.focus(); + }); + } + }, []); + + const stepThreadSearch = useCallback( + (direction: 1 | -1) => { + if (threadSearchResults.length === 0) { + return; + } + setActiveThreadSearchResultIndex((current) => { + if (current < 0) { + return direction > 0 ? 0 : threadSearchResults.length - 1; + } + return (current + direction + threadSearchResults.length) % threadSearchResults.length; + }); + }, + [threadSearchResults.length], + ); + // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. @@ -2237,6 +2409,9 @@ export default function ChatView(props: ChatViewProps) { setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; + setThreadSearchOpen(false); + setThreadSearchQuery(""); + setActiveThreadSearchResultIndex(-1); }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. @@ -2464,6 +2639,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } + const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2473,6 +2649,19 @@ export default function ChatView(props: ChatViewProps) { const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); + + if ( + !command && + isThreadSearchShortcut(event) && + !shortcutContext.terminalFocus && + !expandedImage + ) { + event.preventDefault(); + event.stopPropagation(); + openThreadSearch(!isThreadSearchInputTarget(event.target)); + return; + } + if (!command) return; if (command === "terminal.toggle") { @@ -2541,6 +2730,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + expandedImage, + openThreadSearch, setTerminalOpen, runProjectScript, splitTerminal, @@ -3552,6 +3743,20 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages Wrapper */}
+ {threadSearchOpen && ( +
+ stepThreadSearch(1)} + onPrevious={() => stepThreadSearch(-1)} + onClose={closeThreadSearch} + /> +
+ )} {/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ad54dd8bdeb..742cbff29b0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,6 +2,7 @@ import * as Equal from "effect/Equal"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; export const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -40,6 +41,8 @@ export type MessagesTimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; +export type TimelineRow = MessagesTimelineRow; + export interface StableMessagesTimelineRowsState { byId: Map; result: MessagesTimelineRow[]; @@ -68,6 +71,53 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +export type TimelineWorkEntry = Extract["entry"]; +function capitalizePhrase(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +export function renderableWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); + } + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); +} + +export function renderableWorkEntryPreview( + workEntry: Pick, + workspaceRoot?: string | undefined, +): string | null { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); + return workEntry.changedFiles!.length === 1 + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; +} + +export function renderableWorkEntryChangedFiles( + workEntry: Pick, + workspaceRoot?: string | undefined, +): string[] { + const changedFiles = workEntry.changedFiles ?? []; + if (changedFiles.length === 0) { + return []; + } + if (!workEntry.command && !workEntry.detail) { + return []; + } + return changedFiles + .slice(0, 4) + .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)); +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 12103194870..56d0512df62 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -187,6 +187,59 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-footer="true"'); }); + it("highlights rendered terminal chip labels during search", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- Terminal 1 lines 1-5:", + " 1 | echoed output", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + activeSearchRowId="entry-1" + matchedSearchRowIds={new Set(["entry-1"])} + searchQuery="Terminal 1 lines 1-5" + />, + ); + + expect(markup).toContain("Terminal 1 lines 1-5"); + expect(markup).toContain('data-thread-search-highlight="active"'); + }); + + it("preserves skill chips while highlighting searchable user text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Fix CI Workflow"); + expect(markup).toContain('data-thread-search-highlight="active"'); + }); + it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -238,4 +291,44 @@ describe("MessagesTimeline", () => { expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + + it("exposes hidden work log matches while searching overflowed groups", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( +
+ ); +} diff --git a/apps/web/src/components/chat/threadSearch.test.ts b/apps/web/src/components/chat/threadSearch.test.ts new file mode 100644 index 00000000000..9b7e3b2238a --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.test.ts @@ -0,0 +1,530 @@ +import { MessageId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import type { TimelineRow } from "./MessagesTimeline.logic"; +import { + buildThreadSearchIndex, + findThreadSearchLookupState, + findThreadSearchResults, + findThreadSearchResultsFromIndex, +} from "./threadSearch"; + +const rows: TimelineRow[] = [ + { + kind: "message", + id: "message-row", + createdAt: "2026-03-28T12:00:00.000Z", + durationStart: "2026-03-28T12:00:00.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("message-1"), + role: "assistant", + text: "Needle in the response. Another needle is here.", + createdAt: "2026-03-28T12:00:00.000Z", + streaming: false, + attachments: [ + { + type: "image", + id: "attachment-1", + name: "needle-diagram.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + }, + }, + { + kind: "message", + id: "assistant-markdown-row", + createdAt: "2026-03-28T12:00:02.000Z", + durationStart: "2026-03-28T12:00:02.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("message-1a"), + role: "assistant", + text: [ + "See [thread search docs](https://example.com/thread-search) for **alpha marker** details.", + "", + "```ts", + "const planSeed = 'seeded';", + "```", + ].join("\n"), + createdAt: "2026-03-28T12:00:02.000Z", + streaming: false, + attachments: [], + }, + }, + { + kind: "message", + id: "user-message-row", + createdAt: "2026-03-28T12:00:05.000Z", + durationStart: "2026-03-28T12:00:05.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("message-1b"), + role: "user", + text: [ + "Visible composer text @terminal-1:1-5", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | hidden needle payload", + "", + ].join("\n"), + createdAt: "2026-03-28T12:00:05.000Z", + streaming: false, + attachments: [ + { + type: "image", + id: "attachment-hidden", + name: "hidden-preview-name.png", + mimeType: "image/png", + sizeBytes: 512, + previewUrl: "https://example.com/preview.png", + }, + { + type: "image", + id: "attachment-visible", + name: "visible-upload-name.png", + mimeType: "image/png", + sizeBytes: 256, + }, + ], + }, + }, + { + kind: "work", + id: "work-row", + createdAt: "2026-03-28T12:00:10.000Z", + groupedEntries: [ + { + id: "work-1", + createdAt: "2026-03-28T12:00:10.000Z", + label: "Updated README completed", + toolTitle: "Edit README completed", + detail: "Added the migration note", + command: "bun run lint", + changedFiles: ["README.md"], + tone: "info", + }, + ], + }, + { + kind: "work", + id: "work-row-visible-files", + createdAt: "2026-03-28T12:00:15.000Z", + groupedEntries: [ + { + id: "work-2", + createdAt: "2026-03-28T12:00:15.000Z", + label: "Apply patch completed", + command: "git status", + changedFiles: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], + tone: "info", + }, + ], + }, + { + kind: "proposed-plan", + id: "plan-row", + createdAt: "2026-03-28T12:00:20.000Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: [ + "# Seeded Thread Search Plan", + "", + "## Summary", + "", + "1. Add **thread search**", + "2. Jump to the matching row", + "3. Review [plan docs](https://example.com/plan-docs)", + ].join("\n"), + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-03-28T12:00:20.000Z", + updatedAt: "2026-03-28T12:00:20.000Z", + }, + }, + { + kind: "message", + id: "assistant-empty-row", + createdAt: "2026-03-28T12:00:25.000Z", + durationStart: "2026-03-28T12:00:25.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("message-empty"), + role: "assistant", + text: "", + createdAt: "2026-03-28T12:00:25.000Z", + streaming: false, + attachments: [], + }, + }, + { + kind: "working", + id: "working-row", + createdAt: "2026-03-28T12:00:30.000Z", + }, +]; + +describe("findThreadSearchResults", () => { + it("builds a normalized reusable search index once per row set", () => { + expect(buildThreadSearchIndex(rows)).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + normalizedTexts: ["needle in the response. another needle is here."], + }, + { + rowId: "assistant-markdown-row", + rowIndex: 1, + normalizedTexts: [ + "see thread search docs for alpha marker details.\n\nconst planseed = 'seeded';", + ], + }, + { + rowId: "user-message-row", + rowIndex: 2, + normalizedTexts: ["visible composer text terminal 1 lines 1-5", "visible-upload-name.png"], + }, + { + rowId: "work-row", + rowIndex: 3, + normalizedTexts: ["edit readme", "bun run lint", "readme.md"], + }, + { + rowId: "work-row-visible-files", + rowIndex: 4, + normalizedTexts: [ + "apply patch", + "git status", + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.ts", + ], + }, + { + rowId: "plan-row", + rowIndex: 5, + normalizedTexts: [ + "seeded thread search plan", + "add thread search\njump to the matching row\nreview plan docs", + ], + }, + { + rowId: "assistant-empty-row", + rowIndex: 6, + normalizedTexts: ["(empty response)"], + }, + { + rowId: "working-row", + rowIndex: 7, + normalizedTexts: [], + }, + ]); + }); + + it("finds message matches case-insensitively and counts repeated hits", () => { + expect(findThreadSearchResults(rows, "needle")).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + matchCount: 2, + }, + ]); + }); + + it("matches work log details and changed files", () => { + expect(findThreadSearchResults(rows, "readme")).toEqual([ + { + rowId: "work-row", + rowIndex: 3, + matchCount: 2, + }, + ]); + }); + + it("matches tool titles shown in work log headings", () => { + expect(findThreadSearchResults(rows, "edit readme")).toEqual([ + { + rowId: "work-row", + rowIndex: 3, + matchCount: 1, + }, + ]); + }); + + it("matches displayed proposed-plan body content and ignores the working indicator", () => { + expect(findThreadSearchResults(rows, "jump to the matching row")).toEqual([ + { + rowId: "plan-row", + rowIndex: 5, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "working")).toEqual([]); + }); + + it("ignores hidden terminal-context payloads and hidden preview attachment names", () => { + expect(findThreadSearchResults(rows, "hidden needle payload")).toEqual([]); + expect(findThreadSearchResults(rows, "hidden-preview-name")).toEqual([]); + expect(findThreadSearchResults(rows, "visible composer text")).toEqual([ + { + rowId: "user-message-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "@terminal-1:1-5")).toEqual([]); + expect(findThreadSearchResults(rows, "terminal 1 lines 1-5")).toEqual([ + { + rowId: "user-message-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "visible-upload-name")).toEqual([ + { + rowId: "user-message-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + }); + + it("matches only the rendered work heading and visible changed-file paths", () => { + expect(findThreadSearchResults(rows, "apply patch")).toEqual([ + { + rowId: "work-row-visible-files", + rowIndex: 4, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "completed")).toEqual([]); + expect(findThreadSearchResults(rows, "src/d.ts")).toEqual([ + { + rowId: "work-row-visible-files", + rowIndex: 4, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "src/e.ts")).toEqual([]); + }); + + it("indexes work-entry changed files by the displayed workspace-relative path", () => { + const absolutePathRows: TimelineRow[] = [ + { + kind: "work", + id: "absolute-path-work-row", + createdAt: "2026-03-28T12:00:15.000Z", + groupedEntries: [ + { + id: "absolute-path-work-entry", + createdAt: "2026-03-28T12:00:15.000Z", + label: "Apply patch completed", + command: "git status", + changedFiles: ["/repo/apps/web/src/App.tsx"], + tone: "info", + }, + ], + }, + ]; + + expect( + findThreadSearchResults(absolutePathRows, "apps/web/src/app.tsx", { + workspaceRoot: "/repo", + }), + ).toEqual([ + { + rowId: "absolute-path-work-row", + rowIndex: 0, + matchCount: 1, + }, + ]); + expect( + findThreadSearchResults(absolutePathRows, "/repo/apps/web/src/app.tsx", { + workspaceRoot: "/repo", + }), + ).toEqual([]); + }); + + it("indexes assistant markdown by rendered text instead of raw markdown syntax", () => { + expect(findThreadSearchResults(rows, "thread search docs")).toEqual([ + { + rowId: "assistant-markdown-row", + rowIndex: 1, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "alpha marker")).toEqual([ + { + rowId: "assistant-markdown-row", + rowIndex: 1, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "https://example.com/thread-search")).toEqual([]); + }); + + it("indexes assistant markdown file links by their rendered inline labels", () => { + const fileLinkRows: TimelineRow[] = [ + { + kind: "message", + id: "assistant-file-link-row", + createdAt: "2026-03-28T12:00:02.000Z", + durationStart: "2026-03-28T12:00:02.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("assistant-file-link-message"), + role: "assistant", + text: "Open [the component](file:///repo/apps/web/src/App.tsx#L12C3).", + createdAt: "2026-03-28T12:00:02.000Z", + streaming: false, + attachments: [], + }, + }, + ]; + + expect( + findThreadSearchResults(fileLinkRows, "app.tsx · l12:c3", { + markdownCwd: "/repo", + }), + ).toEqual([ + { + rowId: "assistant-file-link-row", + rowIndex: 0, + matchCount: 1, + }, + ]); + }); + + it("indexes skill tokens by their rendered chip labels", () => { + const skillRows: TimelineRow[] = [ + { + kind: "message", + id: "assistant-skill-row", + createdAt: "2026-03-28T12:00:02.000Z", + durationStart: "2026-03-28T12:00:02.000Z", + showCompletionDivider: false, + completionSummary: null, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("assistant-skill-message"), + role: "assistant", + text: "Run $github:gh-fix-ci next.", + createdAt: "2026-03-28T12:00:02.000Z", + streaming: false, + attachments: [], + }, + }, + ]; + + expect( + findThreadSearchResults(skillRows, "fix ci workflow", { + skills: [{ name: "github:gh-fix-ci", displayName: "Fix CI Workflow" }], + }), + ).toEqual([ + { + rowId: "assistant-skill-row", + rowIndex: 0, + matchCount: 1, + }, + ]); + }); + + it("indexes proposed plans by displayed title and body instead of raw markdown", () => { + expect(findThreadSearchResults(rows, "seeded thread search plan")).toEqual([ + { + rowId: "plan-row", + rowIndex: 5, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "plan docs")).toEqual([ + { + rowId: "plan-row", + rowIndex: 5, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "summary")).toEqual([]); + expect(findThreadSearchResults(rows, "https://example.com/plan-docs")).toEqual([]); + }); + + it("indexes the rendered empty assistant placeholder text", () => { + expect(findThreadSearchResults(rows, "(empty response)")).toEqual([ + { + rowId: "assistant-empty-row", + rowIndex: 6, + matchCount: 1, + }, + ]); + }); + + it("returns no results for empty queries", () => { + expect(findThreadSearchResults(rows, " ")).toEqual([]); + }); + + it("returns matching rows in timeline order when several rows match", () => { + expect(findThreadSearchResults(rows, "row")).toEqual([ + { + rowId: "plan-row", + rowIndex: 5, + matchCount: 1, + }, + ]); + }); + + it("reuses the prebuilt index for result lookup", () => { + const index = buildThreadSearchIndex(rows); + expect(findThreadSearchResultsFromIndex(index, "needle")).toEqual( + findThreadSearchResults(rows, "needle"), + ); + }); + + it("narrows from the previous matching rows when the query extends", () => { + const index = buildThreadSearchIndex(rows); + const previousState = findThreadSearchLookupState(index, "need"); + const nextState = findThreadSearchLookupState(index, "needle", previousState); + + expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual(["message-row"]); + expect(nextState.matchingEntries.map((entry) => entry.rowId)).toEqual(["message-row"]); + expect(nextState.results).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + matchCount: 2, + }, + ]); + }); + + it("rescans the full index when the query broadens", () => { + const index = buildThreadSearchIndex(rows); + const previousState = findThreadSearchLookupState(index, "thread search"); + const nextState = findThreadSearchLookupState(index, "e", previousState); + + expect(previousState.matchingEntries.map((entry) => entry.rowId)).toEqual([ + "assistant-markdown-row", + "plan-row", + ]); + expect(nextState.results).toEqual(findThreadSearchResultsFromIndex(index, "e")); + }); +}); diff --git a/apps/web/src/components/chat/threadSearch.ts b/apps/web/src/components/chat/threadSearch.ts new file mode 100644 index 00000000000..c87a8184912 --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.ts @@ -0,0 +1,211 @@ +import { + renderableWorkEntryChangedFiles, + renderableWorkEntryHeading, + renderableWorkEntryPreview, + type TimelineRow, +} from "./MessagesTimeline.logic"; +import { collectSkillInlineTextLabels } from "./SkillInlineText"; +import { buildRenderedUserMessageText } from "./userMessageTerminalContexts"; +import { stripDisplayedPlanMarkdown, proposedPlanTitle } from "~/proposedPlan"; +import { markdownToPlainText } from "~/lib/markdownPlainText"; +import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; +import { collectMarkdownFileLinkLabels } from "../../markdown-links"; +import type { ServerProviderSkill } from "@t3tools/contracts"; + +export interface ThreadSearchResult { + rowId: string; + rowIndex: number; + matchCount: number; +} + +export interface ThreadSearchIndexEntry { + rowId: string; + rowIndex: number; + normalizedTexts: readonly string[]; +} + +export interface ThreadSearchLookupState { + normalizedQuery: string; + sourceIndex: ReadonlyArray; + matchingEntries: ReadonlyArray; + results: ReadonlyArray; +} + +function normalizeThreadSearchText(value: string): string { + return value.toLocaleLowerCase(); +} + +function countMatches(haystack: string, needle: string): number { + if (needle.length === 0) { + return 0; + } + + let count = 0; + let searchStart = 0; + while (searchStart <= haystack.length - needle.length) { + const matchIndex = haystack.indexOf(needle, searchStart); + if (matchIndex < 0) { + break; + } + count += 1; + searchStart = matchIndex + needle.length; + } + return count; +} + +interface ThreadSearchIndexOptions { + workspaceRoot?: string | undefined; + markdownCwd?: string | undefined; + skills?: ReadonlyArray> | undefined; +} + +function collectRowSearchText(row: TimelineRow, options?: ThreadSearchIndexOptions): string[] { + switch (row.kind) { + case "message": { + const visibleAssistantText = + row.message.role === "assistant" + ? row.message.text || (row.message.streaming ? "" : "(empty response)") + : ""; + const visibleMessageState = + row.message.role === "user" ? deriveDisplayedUserMessageState(row.message.text) : null; + const visibleAttachmentNames = + row.message.role === "user" + ? (row.message.attachments + ?.filter((attachment) => attachment.previewUrl == null) + .map((attachment) => attachment.name) ?? []) + : []; + const visibleAssistantSearchText = + row.message.role === "assistant" + ? [ + markdownToPlainText(visibleAssistantText), + ...collectMarkdownFileLinkLabels(visibleAssistantText, options?.markdownCwd), + ...collectSkillInlineTextLabels(visibleAssistantText, options?.skills ?? []), + ] + : []; + if (row.message.role === "user") { + const visibleUserText = visibleMessageState?.visibleText ?? ""; + return [ + buildRenderedUserMessageText(visibleUserText, visibleMessageState?.contexts ?? []), + ...collectSkillInlineTextLabels(visibleUserText, options?.skills ?? []), + ...visibleAttachmentNames, + ]; + } + return visibleAssistantSearchText; + } + case "proposed-plan": { + const title = proposedPlanTitle(row.proposedPlan.planMarkdown) ?? ""; + const displayedBody = stripDisplayedPlanMarkdown(row.proposedPlan.planMarkdown); + return [title, markdownToPlainText(displayedBody)]; + } + case "work": + return row.groupedEntries.flatMap((entry) => [ + renderableWorkEntryHeading(entry), + renderableWorkEntryPreview(entry, options?.workspaceRoot) ?? "", + ...renderableWorkEntryChangedFiles(entry, options?.workspaceRoot), + ]); + case "working": + return []; + } +} + +export function buildThreadSearchIndex( + rows: ReadonlyArray, + options?: ThreadSearchIndexOptions, +): ReadonlyArray { + return rows.map((row, rowIndex) => ({ + rowId: row.id, + rowIndex, + normalizedTexts: collectRowSearchText(row, options).flatMap((value) => { + const nextValue = normalizeThreadSearchText(value.trim()); + return nextValue.length > 0 ? [nextValue] : []; + }), + })); +} + +function searchCandidateEntries( + candidateEntries: ReadonlyArray, + normalizedQuery: string, +): { + matchingEntries: ReadonlyArray; + results: ReadonlyArray; +} { + const matchingEntries: ThreadSearchIndexEntry[] = []; + const results = candidateEntries.flatMap((entry) => { + const matchCount = entry.normalizedTexts.reduce((total, value) => { + if (!value.includes(normalizedQuery)) { + return total; + } + return total + countMatches(value, normalizedQuery); + }, 0); + if (matchCount <= 0) { + return []; + } + matchingEntries.push(entry); + return [ + { + rowId: entry.rowId, + rowIndex: entry.rowIndex, + matchCount, + } satisfies ThreadSearchResult, + ]; + }); + + return { + matchingEntries, + results, + }; +} + +export function createEmptyThreadSearchLookupState( + index: ReadonlyArray, +): ThreadSearchLookupState { + return { + normalizedQuery: "", + sourceIndex: index, + matchingEntries: [], + results: [], + }; +} + +export function findThreadSearchLookupState( + index: ReadonlyArray, + query: string, + previousState?: ThreadSearchLookupState | null, +): ThreadSearchLookupState { + const normalizedQuery = normalizeThreadSearchText(query.trim()); + if (normalizedQuery.length === 0) { + return createEmptyThreadSearchLookupState(index); + } + + const canNarrowFromPrevious = + previousState !== undefined && + previousState !== null && + previousState.sourceIndex === index && + previousState.normalizedQuery.length > 0 && + normalizedQuery.startsWith(previousState.normalizedQuery); + + const candidateEntries = canNarrowFromPrevious ? previousState.matchingEntries : index; + const { matchingEntries, results } = searchCandidateEntries(candidateEntries, normalizedQuery); + return { + normalizedQuery, + sourceIndex: index, + matchingEntries, + results, + }; +} + +export function findThreadSearchResultsFromIndex( + index: ReadonlyArray, + query: string, + previousState?: ThreadSearchLookupState | null, +): ReadonlyArray { + return findThreadSearchLookupState(index, query, previousState).results; +} + +export function findThreadSearchResults( + rows: ReadonlyArray, + query: string, + options?: ThreadSearchIndexOptions, +): ReadonlyArray { + return findThreadSearchResultsFromIndex(buildThreadSearchIndex(rows, options), query); +} diff --git a/apps/web/src/components/chat/threadSearchHighlight.test.tsx b/apps/web/src/components/chat/threadSearchHighlight.test.tsx new file mode 100644 index 00000000000..1ae0fc6ec22 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + createThreadSearchHighlightRehypePlugin, + renderHighlightedText, +} from "./threadSearchHighlight"; + +describe("createThreadSearchHighlightRehypePlugin", () => { + it("ignores malformed tree children without crashing", () => { + const plugin = createThreadSearchHighlightRehypePlugin("alpha", { active: true }); + if (!plugin) { + throw new Error("Expected highlight plugin to be created."); + } + const transform = plugin(); + + const tree = { + type: "root", + children: [ + undefined, + { + type: "element", + tagName: "p", + children: [{ type: "text", value: "alpha beta alpha" }], + }, + { + type: "element", + tagName: "hr", + }, + ], + }; + + expect(() => transform(tree)).not.toThrow(); + expect(tree.children).toEqual( + expect.arrayContaining([ + { + type: "element", + tagName: "p", + children: [ + { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": "active", + className: + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45", + }, + children: [{ type: "text", value: "alpha" }], + }, + { type: "text", value: " beta " }, + { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": "active", + className: + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45", + }, + children: [{ type: "text", value: "alpha" }], + }, + ], + }, + { + type: "element", + tagName: "hr", + }, + ]), + ); + }); + + it("keeps highlight ranges aligned with original text after locale-expanding characters", () => { + const nodes = renderHighlightedText("İ abc", "abc", "unicode"); + + expect(nodes).toEqual([ + "İ ", + expect.objectContaining({ + props: expect.objectContaining({ + children: "abc", + "data-thread-search-highlight": "match", + }), + }), + ]); + }); +}); diff --git a/apps/web/src/components/chat/threadSearchHighlight.tsx b/apps/web/src/components/chat/threadSearchHighlight.tsx new file mode 100644 index 00000000000..3077ff41403 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.tsx @@ -0,0 +1,183 @@ +import type { ReactNode } from "react"; + +const MATCH_HIGHLIGHT_CLASS_NAME = + "rounded-[0.35rem] bg-warning/38 px-[0.12rem] py-[0.04rem] text-inherit ring-1 ring-warning/18"; +const ACTIVE_HIGHLIGHT_CLASS_NAME = + "rounded-[0.35rem] bg-warning px-[0.12rem] py-[0.04rem] text-black ring-1 ring-warning/45"; + +interface TextMatchRange { + start: number; + end: number; +} + +interface HNode { + type: string; + value?: string; + tagName?: string; + properties?: Record; + children?: unknown; +} + +function normalizeQuery(query: string): string { + return query.trim().toLocaleLowerCase(); +} + +export function textContainsThreadSearchMatch(text: string, query: string): boolean { + return findMatchRanges(text, query).length > 0; +} + +function findMatchRanges(text: string, query: string): TextMatchRange[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery.length === 0) { + return []; + } + + const ranges: TextMatchRange[] = []; + let searchStart = 0; + + while (searchStart < text.length) { + let matchEnd = searchStart; + let normalizedCandidate = ""; + while (matchEnd < text.length && normalizedCandidate.length < normalizedQuery.length) { + matchEnd += 1; + normalizedCandidate = text.slice(searchStart, matchEnd).toLocaleLowerCase(); + } + if (normalizedCandidate === normalizedQuery) { + ranges.push({ + start: searchStart, + end: matchEnd, + }); + searchStart = matchEnd; + continue; + } + searchStart += 1; + } + + return ranges; +} + +export function renderHighlightedText( + text: string, + query: string, + keyPrefix: string, + options?: { active?: boolean }, +): ReactNode { + const ranges = findMatchRanges(text, query); + if (ranges.length === 0) { + return text; + } + + const nodes: ReactNode[] = []; + let cursor = 0; + for (const [index, range] of ranges.entries()) { + if (range.start > cursor) { + nodes.push(text.slice(cursor, range.start)); + } + nodes.push( + + {text.slice(range.start, range.end)} + , + ); + cursor = range.end; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes; +} + +function buildHastHighlightNode(value: string, active: boolean): HNode { + return { + type: "element", + tagName: "mark", + properties: { + "data-thread-search-highlight": active ? "active" : "match", + className: active ? ACTIVE_HIGHLIGHT_CLASS_NAME : MATCH_HIGHLIGHT_CLASS_NAME, + }, + children: [ + { + type: "text", + value, + }, + ], + }; +} + +function splitTextNode(node: HNode, query: string, active: boolean): HNode[] { + const value = typeof node.value === "string" ? node.value : ""; + const ranges = findMatchRanges(value, query); + if (ranges.length === 0) { + return [node]; + } + + const parts: HNode[] = []; + let cursor = 0; + for (const range of ranges) { + if (range.start > cursor) { + parts.push({ + type: "text", + value: value.slice(cursor, range.start), + }); + } + parts.push(buildHastHighlightNode(value.slice(range.start, range.end), active)); + cursor = range.end; + } + + if (cursor < value.length) { + parts.push({ + type: "text", + value: value.slice(cursor), + }); + } + + return parts; +} + +function isHNode(value: unknown): value is HNode { + return typeof value === "object" && value !== null && typeof (value as HNode).type === "string"; +} + +function visitTree(node: HNode, query: string, active: boolean): void { + const rawChildren = Array.isArray(node.children) ? node.children.filter(isHNode) : null; + if (!rawChildren || rawChildren.length === 0) { + return; + } + + const nextChildren: HNode[] = []; + for (const child of rawChildren) { + if (child.type === "text") { + nextChildren.push(...splitTextNode(child, query, active)); + continue; + } + + visitTree(child, query, active); + nextChildren.push(child); + } + + node.children = nextChildren; +} + +export function createThreadSearchHighlightRehypePlugin( + query: string, + options?: { active?: boolean }, +): (() => (tree: unknown) => void) | undefined { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery.length === 0) { + return undefined; + } + + return () => { + return (tree: unknown) => { + if (!isHNode(tree)) { + return; + } + visitTree(tree, normalizedQuery, options?.active ?? false); + }; + }; +} diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts index 110119501d2..d64b98d7fef 100644 --- a/apps/web/src/components/chat/userMessageTerminalContexts.test.ts +++ b/apps/web/src/components/chat/userMessageTerminalContexts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildInlineTerminalContextText, + buildRenderedUserMessageText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; @@ -33,4 +34,27 @@ describe("userMessageTerminalContexts", () => { ]), ).toBe(false); }); + + it("replaces hidden inline terminal tokens with the rendered chip labels", () => { + expect( + buildRenderedUserMessageText("yo @terminal-1:12-13 whats up", [ + { header: "Terminal 1 lines 12-13" }, + ]), + ).toBe("yo Terminal 1 lines 12-13 whats up"); + }); + + it("ignores empty terminal context headers while replacing visible inline labels", () => { + expect( + buildRenderedUserMessageText("yo @terminal-1:12-13 whats up", [ + { header: " " }, + { header: "Terminal 1 lines 12-13" }, + ]), + ).toBe("yo Terminal 1 lines 12-13 whats up"); + }); + + it("prefixes standalone rendered chip labels ahead of the remaining text", () => { + expect( + buildRenderedUserMessageText("follow-up text", [{ header: "Terminal 1 lines 12-13" }]), + ).toBe("Terminal 1 lines 12-13 follow-up text"); + }); }); diff --git a/apps/web/src/components/chat/userMessageTerminalContexts.ts b/apps/web/src/components/chat/userMessageTerminalContexts.ts index 978210a53f4..0d626a7f3fc 100644 --- a/apps/web/src/components/chat/userMessageTerminalContexts.ts +++ b/apps/web/src/components/chat/userMessageTerminalContexts.ts @@ -14,6 +14,14 @@ export function buildInlineTerminalContextText( .join(" "); } +function visibleTerminalContextHeaders( + contexts: ReadonlyArray<{ + header: string; + }>, +): string[] { + return contexts.map((context) => context.header.trim()).filter((header) => header.length > 0); +} + export function formatInlineTerminalContextLabel(header: string): string { const trimmedHeader = header.trim(); const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader); @@ -53,3 +61,39 @@ export function textContainsInlineTerminalContextLabels( return true; } + +export function buildRenderedUserMessageText( + text: string, + contexts: ReadonlyArray<{ + header: string; + }>, +): string { + const headers = visibleTerminalContextHeaders(contexts); + if (headers.length === 0) { + return text; + } + + const visibleContexts = contexts.filter((context) => context.header.trim().length > 0); + + if (textContainsInlineTerminalContextLabels(text, visibleContexts)) { + let cursor = 0; + let renderedText = ""; + + for (const context of visibleContexts) { + const replacement = context.header.trim(); + const label = formatInlineTerminalContextLabel(context.header); + const matchIndex = text.indexOf(label, cursor); + if (matchIndex === -1) { + return text; + } + + renderedText += text.slice(cursor, matchIndex); + renderedText += replacement; + cursor = matchIndex + label.length; + } + + return renderedText + text.slice(cursor); + } + + return text.length > 0 ? `${headers.join(" ")} ${text}` : headers.join(" "); +} diff --git a/apps/web/src/lib/markdownPlainText.test.ts b/apps/web/src/lib/markdownPlainText.test.ts new file mode 100644 index 00000000000..e1efe851f00 --- /dev/null +++ b/apps/web/src/lib/markdownPlainText.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { markdownToPlainText } from "./markdownPlainText"; + +describe("markdownToPlainText", () => { + it("keeps rendered link text and removes raw markdown destinations", () => { + expect( + markdownToPlainText("See [thread search docs](https://example.com/thread-search) next."), + ).toBe("See thread search docs next."); + }); + + it("keeps visible code text while removing markdown fence syntax", () => { + expect(markdownToPlainText("```ts\nconst marker = 'alpha';\n```")).toBe( + "const marker = 'alpha';", + ); + }); + + it("strips single-delimiter emphasis and decodes rendered entities", () => { + expect(markdownToPlainText("it is *important* to decode <div> and _notes_.")).toBe( + "it is important to decode
and notes.", + ); + }); + + it("leaves invalid surrogate numeric entities unchanged instead of throwing", () => { + expect(markdownToPlainText("bad � entity")).toBe("bad � entity"); + }); + + it("strips empty fenced code blocks without leaving fence syntax behind", () => { + expect(markdownToPlainText("Before\n```\n```\nAfter")).toBe("Before\n\n\nAfter"); + }); + + it("removes the first-line markdown structure while preserving visible content", () => { + expect( + markdownToPlainText("# Heading\n\n## Summary\n\n- **alpha marker**\n- `thread search`"), + ).toBe("Heading\nSummary\nalpha marker\nthread search"); + }); + + it("strips nested blockquote markers while preserving quoted text", () => { + expect(markdownToPlainText("> > nested quote")).toBe("nested quote"); + }); +}); diff --git a/apps/web/src/lib/markdownPlainText.ts b/apps/web/src/lib/markdownPlainText.ts new file mode 100644 index 00000000000..0eeb327e826 --- /dev/null +++ b/apps/web/src/lib/markdownPlainText.ts @@ -0,0 +1,80 @@ +import { decodeNamedCharacterReference } from "decode-named-character-reference"; + +const FENCED_CODE_BLOCK_REGEX = /(^|\n)(`{3,}|~{3,})[^\n]*(?:\n([\s\S]*?))?\n?\2(?=\n|$)/g; +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; +const IMAGE_LINK_REGEX = /!\[([^\]]*)\]\((?:\\.|[^)])*\)/g; +const INLINE_LINK_REGEX = /\[([^\]]+)\]\((?:\\.|[^)])*\)/g; +const REFERENCE_LINK_REGEX = /\[([^\]]+)\]\[[^\]]*]/g; +const REFERENCE_DEFINITION_REGEX = /^\s{0,3}\[[^\]]+]:\s+\S+(?:\s+.+)?$/gm; +const HTML_COMMENT_REGEX = //g; +const AUTOLINK_REGEX = /<((?:https?|mailto):[^>\s]+)>/g; +const HTML_TAG_REGEX = /<\/?[^>\n]+>/g; +const HEADING_PREFIX_REGEX = /^\s{0,3}#{1,6}\s+/gm; +const BLOCKQUOTE_PREFIX_REGEX = /^(\s{0,3}>[ \t]*)+/gm; +const LIST_PREFIX_REGEX = /^\s{0,3}(?:[-+*]|\d+[.)])\s+/gm; +const THEMATIC_BREAK_REGEX = /^\s{0,3}(?:[-*_]\s*){3,}$/gm; +const EMPHASIS_REGEXES = [/(\*\*\*|___)(.*?)\1/gs, /(\*\*|__)(.*?)\1/gs, /(~~)(.*?)\1/gs] as const; +const SINGLE_ASTERISK_EMPHASIS_REGEX = /(^|[^\w*])\*(?!\s)([^*\n]+?)(? MAX_UNICODE_CODE_POINT || + (codePoint >= MIN_SURROGATE_CODE_POINT && codePoint <= MAX_SURROGATE_CODE_POINT) + ) { + return entity; + } + + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (entity, rawValue: string) => { + if (rawValue.startsWith("#")) { + return decodeNumericHtmlEntity(entity, rawValue); + } + + return decodeNamedCharacterReference(rawValue) || entity; + }); +} + +export function markdownToPlainText(markdown: string): string { + let text = markdown.replace(/\r\n?/g, "\n"); + + text = text.replace(HTML_COMMENT_REGEX, " "); + text = text.replace( + FENCED_CODE_BLOCK_REGEX, + (_match, prefix: string, _fence: string, code?: string) => `${prefix}${code ?? ""}\n`, + ); + text = text.replace(IMAGE_LINK_REGEX, "$1"); + text = text.replace(INLINE_LINK_REGEX, "$1"); + text = text.replace(REFERENCE_LINK_REGEX, "$1"); + text = text.replace(REFERENCE_DEFINITION_REGEX, ""); + text = text.replace(AUTOLINK_REGEX, "$1"); + text = text.replace(INLINE_CODE_REGEX, "$1"); + + for (const regex of EMPHASIS_REGEXES) { + text = text.replace(regex, "$2"); + } + text = text.replace(SINGLE_ASTERISK_EMPHASIS_REGEX, "$1$2"); + text = text.replace(SINGLE_UNDERSCORE_EMPHASIS_REGEX, "$1$2"); + + text = text.replace(HEADING_PREFIX_REGEX, ""); + text = text.replace(BLOCKQUOTE_PREFIX_REGEX, ""); + text = text.replace(LIST_PREFIX_REGEX, ""); + text = text.replace(THEMATIC_BREAK_REGEX, ""); + text = text.replace(HTML_TAG_REGEX, " "); + text = decodeHtmlEntities(text); + + return text.trim(); +} diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 9ec4ee4564c..e103087bb71 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -9,6 +9,7 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", @@ -174,6 +175,97 @@ function basenameOfPath(path: string): string { return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; } +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +export function buildFileLinkParentSuffixByPath( + filePaths: ReadonlyArray, +): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const pathSegments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(filePath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + return suffixByPath; +} + +export function buildMarkdownFileLinkLabel( + meta: Pick, + parentSuffix?: string | undefined, +): string { + const labelParts = [meta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (meta.line) { + labelParts.push(`L${meta.line}${meta.column ? `:C${meta.column}` : ""}`); + } + return labelParts.join(" · "); +} + +export function extractMarkdownLinkHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = match[1]?.trim(); + if (!href) continue; + hrefs.push(href); + } + return hrefs; +} + +export function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; +} + export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, @@ -196,3 +288,22 @@ export function resolveMarkdownFileLinkMeta( ...(columnNumber !== undefined ? { column: columnNumber } : {}), }; } + +export function collectMarkdownFileLinkLabels(text: string, cwd?: string): string[] { + const metaByHref = new Map(); + for (const href of extractMarkdownLinkHrefs(text)) { + const normalizedHref = normalizeMarkdownLinkHrefKey(href); + if (metaByHref.has(normalizedHref)) continue; + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + if (meta) { + metaByHref.set(normalizedHref, meta); + } + } + + const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath( + [...metaByHref.values()].map((meta) => meta.filePath), + ); + return [...metaByHref.values()].map((meta) => + buildMarkdownFileLinkLabel(meta, fileLinkParentSuffixByPath.get(meta.filePath)), + ); +} diff --git a/bun.lock b/bun.lock index ffc4a5922bd..275bf2bdf80 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", @@ -50,7 +50,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.23", + "version": "0.0.24", "bin": { "t3": "./dist/bin.mjs", }, @@ -83,7 +83,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@base-ui/react": "^1.4.1", "@dnd-kit/core": "^6.3.1", @@ -104,6 +104,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "decode-named-character-reference": "^1.3.0", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", @@ -165,7 +166,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "effect": "catalog:", },