From 6851efc3f10bc1d00fadcc6676f10dc123867888 Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:24:14 -0300 Subject: [PATCH 01/12] Add in-thread search for chat conversations --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.test.tsx | 27 + apps/web/src/components/ChatMarkdown.tsx | 18 + .../ChatView.threadSearch.browser.tsx | 516 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 196 ++++++- .../components/chat/MessagesTimeline.logic.ts | 30 + .../src/components/chat/MessagesTimeline.tsx | 240 +++++++- .../components/chat/ProposedPlanCard.test.tsx | 28 + .../src/components/chat/ProposedPlanCard.tsx | 41 +- .../chat/TerminalContextInlineChip.tsx | 9 +- .../chat/ThreadSearchBar.browser.tsx | 122 +++++ .../src/components/chat/ThreadSearchBar.tsx | 116 ++++ .../src/components/chat/threadSearch.test.ts | 180 ++++++ apps/web/src/components/chat/threadSearch.ts | 163 ++++++ .../chat/threadSearchHighlight.test.tsx | 66 +++ .../components/chat/threadSearchHighlight.tsx | 174 ++++++ apps/web/src/lib/markdownPlainText.ts | 77 +++ 17 files changed, 1965 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx create mode 100644 apps/web/src/components/ChatView.threadSearch.browser.tsx create mode 100644 apps/web/src/components/chat/ProposedPlanCard.test.tsx create mode 100644 apps/web/src/components/chat/ThreadSearchBar.browser.tsx create mode 100644 apps/web/src/components/chat/ThreadSearchBar.tsx create mode 100644 apps/web/src/components/chat/threadSearch.test.ts create mode 100644 apps/web/src/components/chat/threadSearch.ts create mode 100644 apps/web/src/components/chat/threadSearchHighlight.test.tsx create mode 100644 apps/web/src/components/chat/threadSearchHighlight.tsx create mode 100644 apps/web/src/lib/markdownPlainText.ts 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..38c53991027 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,27 @@ +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<"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6a85ccee96a..6cafec7b565 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -28,6 +28,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; +import { createThreadSearchHighlightRehypePlugin } from "./chat/threadSearchHighlight"; import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, @@ -62,6 +63,8 @@ interface ChatMarkdownProps { cwd: string | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; + searchQuery?: string; + searchActive?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -517,9 +520,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, @@ -585,6 +594,13 @@ function ChatMarkdown({ if (!codeBlock) { return
{children}
; } + if (searchQuery.trim().length > 0) { + return ( + +
{children}
+
+ ); + } return ( @@ -608,6 +624,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, resolvedTheme, + searchQuery, skills, ], ); @@ -616,6 +633,7 @@ function ChatMarkdown({
diff --git a/apps/web/src/components/ChatView.threadSearch.browser.tsx b/apps/web/src/components/ChatView.threadSearch.browser.tsx new file mode 100644 index 00000000000..5074fe23fc0 --- /dev/null +++ b/apps/web/src/components/ChatView.threadSearch.browser.tsx @@ -0,0 +1,516 @@ +import "../index.css"; + +import { + ORCHESTRATION_WS_METHODS, + DEFAULT_SERVER_SETTINGS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; +import { page } from "vitest/browser"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "../composerDraftStore"; +import { getRouter } from "../router"; +import { useStore } from "../store"; +import { isMacPlatform } from "../lib/utils"; + +const THREAD_ID = "thread-search-browser" as ThreadId; +const PROJECT_ID = "project-1" as ProjectId; +const NOW_ISO = "2026-03-04T12:00:00.000Z"; +const BASE_TIME_MS = Date.parse(NOW_ISO); + +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; +} + +let fixture: TestFixture; +const wsLink = ws.link(/ws(s)?:\/\/.*/); + +function isoAt(offsetSeconds: number): string { + return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); +} + +function createBaseServerConfig(): ServerConfig { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + authStatus: "authenticated", + checkedAt: NOW_ISO, + models: [], + }, + ], + availableEditors: [], + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, + }; +} + +function createSearchSnapshot(): OrchestrationReadModel { + const messages: Array = []; + + for (let index = 0; index < 24; index += 1) { + const userId = `user-${index}` as MessageId; + const assistantId = `assistant-${index}` as MessageId; + + const userText = + index === 0 + ? "virtualized alpha marker near the top" + : index === 8 + ? "second alpha marker closer to the middle" + : `filler user message ${index}`; + + messages.push({ + id: userId, + role: "user", + text: userText, + turnId: null, + streaming: false, + createdAt: isoAt(messages.length * 3), + updatedAt: isoAt(messages.length * 3 + 1), + }); + messages.push({ + id: assistantId, + role: "assistant", + text: `assistant filler ${index}`, + turnId: null, + streaming: false, + createdAt: isoAt(messages.length * 3), + updatedAt: isoAt(messages.length * 3 + 1), + }); + } + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5", + }, + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread search test thread", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + archivedAt: null, + deletedAt: null, + messages, + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + updatedAt: NOW_ISO, + }; +} + +function buildFixture(): TestFixture { + return { + snapshot: createSearchSnapshot(), + serverConfig: createBaseServerConfig(), + welcome: { + cwd: "/repo/project", + projectName: "Project", + bootstrapProjectId: PROJECT_ID, + bootstrapThreadId: THREAD_ID, + }, + }; +} + +function resolveWsRpc(tag: string): unknown { + if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { + return fixture.snapshot; + } + if (tag === WS_METHODS.serverGetConfig) { + return fixture.serverConfig; + } + if (tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], + }; + } + if (tag === WS_METHODS.gitStatus) { + return { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + } + if (tag === WS_METHODS.projectsSearchEntries) { + return { entries: [], truncated: false }; + } + return {}; +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + client.send( + JSON.stringify({ + type: "push", + sequence: 1, + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: { id: string; body: { _tag: string; [key: string]: unknown } }; + try { + request = JSON.parse(rawData); + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { timeout: 8_000, interval: 16 }, + ); + return element!; +} + +async function waitForComposerEditor(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="composer-editor"]'), + "ChatView should render the composer editor", + ); +} + +async function waitForSearchInput(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="thread-search-input"]'), + "Thread search input should be visible", + ); +} + +function dispatchThreadSearchShortcut() { + const useMetaForMod = isMacPlatform(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "f", + metaKey: useMetaForMod, + ctrlKey: !useMetaForMod, + bubbles: true, + cancelable: true, + }), + ); +} + +function dispatchSearchInputKey(key: string, options: { shiftKey?: boolean } = {}) { + const input = document.querySelector('[data-testid="thread-search-input"]'); + if (!input) { + throw new Error("Thread search input is not mounted"); + } + input.dispatchEvent( + new KeyboardEvent("keydown", { + key, + shiftKey: options.shiftKey ?? false, + bubbles: true, + cancelable: true, + }), + ); +} + +async function mountApp(): Promise<{ cleanup: () => Promise }> { + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const screen = await render(, { container: host }); + await waitForComposerEditor(); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +async function waitForActiveMessageRow(messageId: string): Promise { + return waitForElement( + () => + document.querySelector( + `[data-message-id="${messageId}"][data-search-match-state="active"]`, + ), + `Message row ${messageId} should be the active search result`, + ); +} + +async function waitForActiveSearchHighlight(messageId: string, text: string): Promise { + return waitForElement(() => { + const row = document.querySelector( + `[data-message-id="${messageId}"][data-search-match-state="active"]`, + ); + if (!row) { + return null; + } + return ( + Array.from( + row.querySelectorAll('mark[data-thread-search-highlight="active"]'), + ).find((element) => element.textContent?.toLowerCase() === text.toLowerCase()) ?? null + ); + }, `Message row ${messageId} should highlight "${text}" inline`); +} + +async function waitForMessageRow(messageId: string): Promise { + return waitForElement( + () => document.querySelector(`[data-message-id="${messageId}"]`), + `Message row ${messageId} should be rendered`, + ); +} + +async function waitForAnyTimelineRow(): Promise { + return waitForElement( + () => document.querySelector("[data-timeline-row-id]"), + "At least one timeline row should be rendered", + ); +} + +describe("ChatView thread search", () => { + beforeAll(async () => { + fixture = buildFixture(); + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + beforeEach(() => { + fixture = buildFixture(); + localStorage.clear(); + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("opens with Cmd/Ctrl+F and restores composer focus when dismissed", async () => { + const mounted = await mountApp(); + + try { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + dispatchThreadSearchShortcut(); + + const searchInput = await waitForSearchInput(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + await vi.waitFor(() => { + expect(document.activeElement).toBe(searchInput); + }); + + dispatchSearchInputKey("Escape"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-input"]')).toBeNull(); + expect(document.activeElement?.getAttribute("data-testid")).toBe("composer-editor"); + expect(document.querySelector('[data-thread-search-highlight="active"]')).toBeNull(); + expect(document.querySelector('[data-search-match-state="active"]')).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("does not shift the thread layout when opened", async () => { + const mounted = await mountApp(); + + try { + const firstRenderedRow = await waitForAnyTimelineRow(); + const trackedRowId = firstRenderedRow.dataset.timelineRowId; + const beforeTop = firstRenderedRow.getBoundingClientRect().top; + + dispatchThreadSearchShortcut(); + await waitForSearchInput(); + + await vi.waitFor(() => { + const afterTop = document + .querySelector(`[data-timeline-row-id="${trackedRowId}"]`) + ?.getBoundingClientRect().top; + expect(afterTop).toBeDefined(); + expect(Math.abs((afterTop ?? 0) - beforeTop)).toBeLessThan(1); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the no-match state and disables result navigation", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + const searchInput = await waitForSearchInput(); + searchInput.focus(); + await page.getByTestId("thread-search-input").fill("does-not-exist"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-count"]')?.textContent).toBe( + "No matches", + ); + }); + await expect.element(page.getByLabelText("Previous search result")).toBeDisabled(); + await expect.element(page.getByLabelText("Next search result")).toBeDisabled(); + } finally { + await mounted.cleanup(); + } + }); + + it("cycles between matches with Enter, Shift+Enter, and the next button", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-count"]')?.textContent).toBe( + "1 / 2", + ); + }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + dispatchSearchInputKey("Enter"); + await waitForActiveMessageRow("user-8"); + await waitForActiveSearchHighlight("user-8", "alpha marker"); + + dispatchSearchInputKey("Enter", { shiftKey: true }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + await page.getByLabelText("Next search result").click(); + await waitForActiveMessageRow("user-8"); + await waitForActiveSearchHighlight("user-8", "alpha marker"); + } finally { + await mounted.cleanup(); + } + }); + + it("pulls an older virtualized match into the DOM when selected", async () => { + const mounted = await mountApp(); + + try { + expect(document.body.textContent ?? "").not.toContain( + "virtualized alpha marker near the top", + ); + + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("virtualized alpha marker near the top"); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("virtualized alpha marker near the top"); + }); + await waitForActiveMessageRow("user-0"); + await waitForActiveSearchHighlight("user-0", "virtualized alpha marker near the top"); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..f36e2cae6ae 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 } 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,7 @@ 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[] = []; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -208,6 +218,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 +716,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 +761,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,6 +1659,81 @@ export default function ChatView(props: ChatViewProps) { if (!completionSummary) return null; return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); + const threadSearchRows = useMemo( + () => + deriveMessagesTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + isWorking, + activeTurnInProgress: isWorking || !latestTurnSettled, + activeTurnId: activeLatestTurn?.turnId ?? null, + activeTurnStartedAt: activeWorkStartedAt, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId, + }), + [ + activeLatestTurn?.turnId, + activeWorkStartedAt, + completionDividerBeforeEntryId, + completionSummary, + isWorking, + latestTurnSettled, + revertTurnCountByUserMessageId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ], + ); + const deferredThreadSearchQuery = useDeferredValue(threadSearchQuery); + const threadSearchIndex = useMemo( + () => + threadSearchOpen ? buildThreadSearchIndex(threadSearchRows) : EMPTY_THREAD_SEARCH_INDEX, + [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)) + : new Set(), + [threadSearchResults], + ); + const activeThreadSearchRowId = + threadSearchOpen && activeThreadSearchResultIndex >= 0 + ? (threadSearchResults[activeThreadSearchResultIndex]?.rowId ?? null) + : null; + + useEffect(() => { + const normalizedQuery = threadSearchQuery.trim(); + setActiveThreadSearchResultIndex(normalizedQuery.length > 0 ? 0 : -1); + }, [threadSearchQuery]); + + useEffect(() => { + setActiveThreadSearchResultIndex((current) => { + if (threadSearchResults.length === 0) { + return -1; + } + if (current < 0) { + return 0; + } + return Math.min(current, threadSearchResults.length - 1); + }); + }, [threadSearchResults]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -2207,6 +2317,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 (!isThreadSearchInputTarget(activeElement)) { + 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 +2401,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 +2631,14 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } + + if (isThreadSearchShortcut(event) && !isTerminalFocused() && !expandedImage) { + event.preventDefault(); + event.stopPropagation(); + openThreadSearch(!isThreadSearchInputTarget(event.target)); + return; + } + const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), @@ -2541,6 +2716,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + expandedImage, + openThreadSearch, setTerminalOpen, runProjectScript, splitTerminal, @@ -3552,6 +3729,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..cdbe28420f5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -40,6 +40,8 @@ export type MessagesTimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; +export type TimelineRow = MessagesTimelineRow; + export interface StableMessagesTimelineRowsState { byId: Map; result: MessagesTimelineRow[]; @@ -68,6 +70,34 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +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: WorkLogEntry): string { + const label = workEntry.toolTitle ?? workEntry.label; + return capitalizePhrase(normalizeCompactToolLabel(label)); +} + +export function renderableWorkEntryPreview( + workEntry: Pick, +): string | null { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + return workEntry.changedFiles?.join("\n") ?? null; +} + +export function renderableWorkEntryChangedFiles( + workEntry: Pick, +): string[] { + return workEntry.changedFiles ?? []; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1540d5f344a..fd8835f0948 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -67,6 +67,7 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { renderHighlightedText } from "./threadSearchHighlight"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. @@ -126,6 +127,9 @@ interface MessagesTimelineProps { workspaceRoot: string | undefined; skills?: ReadonlyArray>; onIsAtEndChange: (isAtEnd: boolean) => void; + activeSearchRowId?: string | null; + matchedSearchRowIds?: ReadonlySet; + searchQuery?: string; } // --------------------------------------------------------------------------- @@ -155,6 +159,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, + activeSearchRowId = null, + matchedSearchRowIds = new Set(), + searchQuery = "", }: MessagesTimelineProps) { const rawRows = useMemo( () => @@ -208,6 +215,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [listRef, onIsAtEndChange, rows.length]); + const rowIndexById = useMemo(() => new Map(rows.map((row, index) => [row.id, index])), [rows]); + + useEffect(() => { + if (!activeSearchRowId) { + return; + } + + const rowIndex = rowIndexById.get(activeSearchRowId); + if (rowIndex === undefined) { + return; + } + + void listRef.current?.scrollToIndex?.({ index: rowIndex, animated: false }); + }, [activeSearchRowId, listRef, rowIndexById]); + const sharedState = useMemo( () => ({ timestampFormat, @@ -245,12 +267,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( - ({ item }: { item: MessagesTimelineRow }) => ( -
- -
- ), - [], + ({ item }: { item: MessagesTimelineRow }) => { + const searchState = + activeSearchRowId === item.id + ? "active" + : matchedSearchRowIds.has(item.id) + ? "matched" + : null; + + return ( +
+ +
+ ); + }, + [activeSearchRowId, matchedSearchRowIds, searchQuery], ); if (rows.length === 0 && !isWorking) { @@ -299,7 +334,17 @@ type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; -const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { +const TimelineRowContent = memo(function TimelineRowContent({ + row, + searchState, + searchQuery, +}: { + row: TimelineRow; + searchState: "active" | "matched" | null; + searchQuery: string; +}) { + const searchActive = searchState === "active"; + return (
- {row.kind === "work" ? : null} - {row.kind === "message" && row.message.role === "user" ? : null} + {row.kind === "work" ? ( + + ) : null} + {row.kind === "message" && row.message.role === "user" ? ( + + ) : null} {row.kind === "message" && row.message.role === "assistant" ? ( - + + ) : null} + {row.kind === "proposed-plan" ? ( + ) : null} - {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null}
); }); -function UserTimelineRow({ row }: { row: Extract }) { +function UserTimelineRow({ + row, + searchQuery, + searchActive, +}: { + row: Extract; + searchQuery: string; + searchActive: boolean; +}) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); @@ -369,6 +433,8 @@ function UserTimelineRow({ row }: { row: Extract
@@ -406,7 +472,15 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { ); } -function AssistantTimelineRow({ row }: { row: Extract }) { +function AssistantTimelineRow({ + row, + searchQuery, + searchActive, +}: { + row: Extract; + searchQuery: string; + searchActive: boolean; +}) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); @@ -421,6 +495,8 @@ function AssistantTimelineRow({ row }: { row: Extract ; + searchQuery: string; + searchActive: boolean; }) { const ctx = use(TimelineRowCtx); @@ -500,6 +580,8 @@ function ProposedPlanTimelineRow({ environmentId={ctx.activeThreadEnvironmentId} cwd={ctx.markdownCwd} workspaceRoot={ctx.workspaceRoot} + searchQuery={searchQuery} + searchActive={searchActive} />
); @@ -595,14 +677,19 @@ function LiveMessageMeta({ * State resets on unmount which is fine — work groups start collapsed. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, + searchQuery, + searchActive, }: { groupedEntries: Extract["groupedEntries"]; + searchQuery: string; + searchActive: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const searchExpanded = searchQuery.trim().length > 0 && hasOverflow; const visibleEntries = - hasOverflow && !isExpanded + hasOverflow && !isExpanded && !searchExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; @@ -617,7 +704,7 @@ const WorkGroupSection = memo(function WorkGroupSection({

{groupLabel} ({groupedEntries.length})

- {hasOverflow && ( + {hasOverflow && !searchExpanded && (
@@ -740,13 +829,24 @@ function AssistantChangedFilesSectionInner({ // --------------------------------------------------------------------------- const UserMessageTerminalContextInlineLabel = memo( - function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { + function UserMessageTerminalContextInlineLabel(props: { + context: ParsedTerminalContextEntry; + searchQuery: string; + searchActive: boolean; + }) { const tooltipText = props.context.body.length > 0 ? `${props.context.header}\n${props.context.body}` : props.context.header; - return ; + return ( + + ); }, ); @@ -770,12 +870,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop text: string; terminalContexts: ParsedTerminalContextEntry[]; skills: ReadonlyArray>; + searchQuery: string; + searchActive: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); - const isCollapsed = canCollapse && !expanded; + const searchIsActive = props.searchQuery.trim().length > 0; + const isCollapsed = canCollapse && !expanded && !searchIsActive; return (
@@ -799,6 +902,8 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop text={props.text} terminalContexts={props.terminalContexts} skills={props.skills} + searchQuery={props.searchQuery} + searchActive={props.searchActive} />
) : null} @@ -810,7 +915,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop )} data-user-message-footer="true" > - {canCollapse ? ( + {canCollapse && !searchIsActive ? ( + + +
+
+ ); +} 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..528b5964e8e --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.test.ts @@ -0,0 +1,180 @@ +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, + message: { + id: MessageId.makeUnsafe("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: "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", + detail: "Added the migration note", + command: "bun run lint", + changedFiles: ["README.md"], + tone: "info", + }, + ], + }, + { + kind: "proposed-plan", + id: "plan-row", + createdAt: "2026-03-28T12:00:20.000Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Add thread search\n2. Jump to the matching row", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-03-28T12:00:20.000Z", + updatedAt: "2026-03-28T12:00:20.000Z", + }, + }, + { + 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.", "needle-diagram.png"], + }, + { + rowId: "work-row", + rowIndex: 1, + normalizedTexts: [ + "updated readme", + "added the migration note", + "bun run lint", + "readme.md", + ], + }, + { + rowId: "plan-row", + rowIndex: 2, + normalizedTexts: ["1. add thread search\n2. jump to the matching row"], + }, + { + rowId: "working-row", + rowIndex: 3, + normalizedTexts: [], + }, + ]); + }); + + it("finds message matches case-insensitively and counts repeated hits", () => { + expect(findThreadSearchResults(rows, "needle")).toEqual([ + { + rowId: "message-row", + rowIndex: 0, + matchCount: 3, + }, + ]); + }); + + it("matches work log details and changed files", () => { + expect(findThreadSearchResults(rows, "readme")).toEqual([ + { + rowId: "work-row", + rowIndex: 1, + matchCount: 2, + }, + ]); + }); + + it("matches proposed plans and ignores the working indicator", () => { + expect(findThreadSearchResults(rows, "thread search")).toEqual([ + { + rowId: "plan-row", + rowIndex: 2, + matchCount: 1, + }, + ]); + expect(findThreadSearchResults(rows, "working")).toEqual([]); + }); + + 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: 2, + 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: 3, + }, + ]); + }); + + 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(["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..203bf876b63 --- /dev/null +++ b/apps/web/src/components/chat/threadSearch.ts @@ -0,0 +1,163 @@ +import type { TimelineRow } from "./MessagesTimeline.logic"; + +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; +} + +function collectRowSearchText(row: TimelineRow): string[] { + switch (row.kind) { + case "message": + return [ + row.message.text, + ...(row.message.attachments?.map((attachment) => attachment.name) ?? []), + ]; + case "proposed-plan": + return [row.proposedPlan.planMarkdown]; + case "work": + return row.groupedEntries.flatMap((entry) => [ + entry.label, + entry.detail ?? "", + entry.command ?? "", + ...(entry.changedFiles ?? []), + ]); + case "working": + return []; + } +} + +export function buildThreadSearchIndex( + rows: ReadonlyArray, +): ReadonlyArray { + return rows.map((row, rowIndex) => ({ + rowId: row.id, + rowIndex, + normalizedTexts: collectRowSearchText(row).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, +): ReadonlyArray { + return findThreadSearchResultsFromIndex(buildThreadSearchIndex(rows), 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..36d1a5fbee8 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { createThreadSearchHighlightRehypePlugin } 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", + }, + ]), + ); + }); +}); diff --git a/apps/web/src/components/chat/threadSearchHighlight.tsx b/apps/web/src/components/chat/threadSearchHighlight.tsx new file mode 100644 index 00000000000..79b99588929 --- /dev/null +++ b/apps/web/src/components/chat/threadSearchHighlight.tsx @@ -0,0 +1,174 @@ +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(); +} + +function findMatchRanges(text: string, query: string): TextMatchRange[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery.length === 0) { + return []; + } + + const normalizedText = text.toLocaleLowerCase(); + const ranges: TextMatchRange[] = []; + let searchStart = 0; + + while (searchStart <= normalizedText.length - normalizedQuery.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, searchStart); + if (matchIndex < 0) { + break; + } + ranges.push({ + start: matchIndex, + end: matchIndex + normalizedQuery.length, + }); + searchStart = matchIndex + normalizedQuery.length; + } + + 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/lib/markdownPlainText.ts b/apps/web/src/lib/markdownPlainText.ts new file mode 100644 index 00000000000..a371c226dda --- /dev/null +++ b/apps/web/src/lib/markdownPlainText.ts @@ -0,0 +1,77 @@ +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}>\s?/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 + ) { + 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(); +} From 3bc2ef0d4748cadcf02c73d1d48ed6baac312219 Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:42:43 -0300 Subject: [PATCH 02/12] Address thread search review feedback --- .../ChatView.threadSearch.browser.tsx | 83 +++++++++++++++++-- .../components/chat/MessagesTimeline.test.tsx | 40 +++++++++ .../src/components/chat/threadSearch.test.ts | 14 +++- apps/web/src/components/chat/threadSearch.ts | 23 +++-- 4 files changed, 145 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.threadSearch.browser.tsx b/apps/web/src/components/ChatView.threadSearch.browser.tsx index 5074fe23fc0..d0ab43155b8 100644 --- a/apps/web/src/components/ChatView.threadSearch.browser.tsx +++ b/apps/web/src/components/ChatView.threadSearch.browser.tsx @@ -26,6 +26,7 @@ import { useStore } from "../store"; import { isMacPlatform } from "../lib/utils"; const THREAD_ID = "thread-search-browser" as ThreadId; +const SECOND_THREAD_ID = "thread-search-browser-second" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); @@ -152,6 +153,47 @@ function createSearchSnapshot(): OrchestrationReadModel { updatedAt: NOW_ISO, }, }, + { + id: SECOND_THREAD_ID, + projectId: PROJECT_ID, + title: "Second thread", + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + archivedAt: null, + deletedAt: null, + messages: [ + { + id: "second-thread-message-1" as MessageId, + role: "assistant", + text: "This second thread should not inherit any stale search state.", + turnId: null, + streaming: false, + createdAt: isoAt(500), + updatedAt: isoAt(501), + }, + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: SECOND_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, ], updatedAt: NOW_ISO, }; @@ -291,7 +333,10 @@ function dispatchSearchInputKey(key: string, options: { shiftKey?: boolean } = { ); } -async function mountApp(): Promise<{ cleanup: () => Promise }> { +async function mountApp(): Promise<{ + cleanup: () => Promise; + router: ReturnType; +}> { const host = document.createElement("div"); host.style.position = "fixed"; host.style.inset = "0"; @@ -306,6 +351,7 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { await waitForComposerEditor(); return { + router, cleanup: async () => { await screen.unmount(); host.remove(); @@ -339,13 +385,6 @@ async function waitForActiveSearchHighlight(messageId: string, text: string): Pr }, `Message row ${messageId} should highlight "${text}" inline`); } -async function waitForMessageRow(messageId: string): Promise { - return waitForElement( - () => document.querySelector(`[data-message-id="${messageId}"]`), - `Message row ${messageId} should be rendered`, - ); -} - async function waitForAnyTimelineRow(): Promise { return waitForElement( () => document.querySelector("[data-timeline-row-id]"), @@ -513,4 +552,32 @@ describe("ChatView thread search", () => { await mounted.cleanup(); } }); + + it("resets the search UI and query when navigating to another thread", async () => { + const mounted = await mountApp(); + + try { + dispatchThreadSearchShortcut(); + await page.getByTestId("thread-search-input").fill("alpha marker"); + await waitForActiveSearchHighlight("user-0", "alpha marker"); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: SECOND_THREAD_ID }, + }); + + await waitForElement( + () => document.querySelector('[data-message-id="second-thread-message-1"]'), + "Second thread content should be rendered after navigation", + ); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-input"]')).toBeNull(); + expect(document.querySelector('[data-search-match-state="active"]')).toBeNull(); + expect(document.querySelector('[data-thread-search-highlight="active"]')).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 12103194870..f4e3c5c8622 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -238,4 +238,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( +