From 72d67ca4fb81ff2f8347fa7e3dd78b540567a1ba Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:24:14 -0300 Subject: [PATCH 1/6] Add in-thread search for chat conversations --- apps/web/src/components/ChatMarkdown.test.tsx | 27 + apps/web/src/components/ChatMarkdown.tsx | 21 +- .../ChatView.threadSearch.browser.tsx | 516 +++++++++++++ apps/web/src/components/ChatView.tsx | 171 ++++- .../chat/MessagesTimeline.logic.test.ts | 151 +++- .../components/chat/MessagesTimeline.logic.ts | 99 +++ .../components/chat/MessagesTimeline.test.tsx | 209 ++++- .../src/components/chat/MessagesTimeline.tsx | 723 +++++++++--------- .../components/chat/ProposedPlanCard.test.tsx | 28 + .../src/components/chat/ProposedPlanCard.tsx | 27 +- .../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 +++++ 16 files changed, 2392 insertions(+), 401 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 diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 0000000000..38c5399102 --- /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 b364a8e3a1..68dbd915db 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -23,6 +23,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { createThreadSearchHighlightRehypePlugin } from "./chat/threadSearchHighlight"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -49,6 +50,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; isStreaming?: boolean; + searchQuery?: string; + searchActive?: boolean; } const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -235,9 +238,19 @@ function SuspenseShikiCodeBlock({ ); } -function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { +function ChatMarkdown({ + text, + cwd, + isStreaming = false, + searchQuery = "", + searchActive = false, +}: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const searchHighlightPlugin = useMemo( + () => createThreadSearchHighlightRehypePlugin(searchQuery, { active: searchActive }), + [searchActive, searchQuery], + ); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { @@ -290,7 +303,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
- + {text}
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 0000000000..5074fe23fc --- /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 1d926bf308..50093b722c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,15 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -99,7 +107,7 @@ import { import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -145,6 +153,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { buildTimelineRows } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -162,6 +171,13 @@ import { } from "./chat/composerProviderRegistry"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { ThreadSearchBar } from "./chat/ThreadSearchBar"; +import { + buildThreadSearchIndex, + createEmptyThreadSearchLookupState, + findThreadSearchLookupState, + type ThreadSearchLookupState, +} from "./chat/threadSearch"; import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -206,6 +222,23 @@ function formatOutgoingPrompt(params: { const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const THREAD_SEARCH_INPUT_SELECTOR = "[data-testid='thread-search-input']"; + +function isThreadSearchShortcut(event: KeyboardEvent, platform = navigator.platform): boolean { + if (event.key.toLowerCase() !== "f") { + return false; + } + if (event.shiftKey || event.altKey) { + return false; + } + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +function isThreadSearchInputTarget(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.closest(THREAD_SEARCH_INPUT_SELECTOR) !== null; +} const extendReplacementRangeForTrailingSpace = ( text: string, @@ -321,6 +354,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [threadSearchOpen, setThreadSearchOpen] = useState(false); + const [threadSearchQuery, setThreadSearchQuery] = useState(""); + const [activeThreadSearchResultIndex, setActiveThreadSearchResultIndex] = useState(-1); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -398,6 +434,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const threadSearchInputRef = useRef(null); + const threadSearchRestoreFocusRef = useRef(null); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); @@ -1009,6 +1047,57 @@ export default function ChatView({ threadId }: ChatViewProps) { latestTurnSettled, timelineEntries, ]); + const timelineRows = useMemo( + () => + buildTimelineRows({ + timelineEntries, + completionDividerBeforeEntryId, + isWorking, + activeTurnStartedAt: activeWorkStartedAt, + }), + [activeWorkStartedAt, completionDividerBeforeEntryId, isWorking, timelineEntries], + ); + const deferredThreadSearchQuery = useDeferredValue(threadSearchQuery); + const threadSearchIndex = useMemo(() => buildThreadSearchIndex(timelineRows), [timelineRows]); + const threadSearchLookupStateRef = useRef( + createEmptyThreadSearchLookupState(threadSearchIndex), + ); + const threadSearchResults = useMemo(() => { + const nextLookupState = findThreadSearchLookupState( + threadSearchIndex, + deferredThreadSearchQuery, + threadSearchLookupStateRef.current, + ); + threadSearchLookupStateRef.current = nextLookupState; + return nextLookupState.results; + }, [deferredThreadSearchQuery, threadSearchIndex]); + const visibleThreadSearchResults = useMemo( + () => (threadSearchOpen ? threadSearchResults : []), + [threadSearchOpen, threadSearchResults], + ); + const matchedThreadSearchRowIds = useMemo( + () => new Set(visibleThreadSearchResults.map((result) => result.rowId)), + [visibleThreadSearchResults], + ); + const activeThreadSearchRowId = + threadSearchOpen && activeThreadSearchResultIndex >= 0 + ? (visibleThreadSearchResults[activeThreadSearchResultIndex]?.rowId ?? null) + : null; + useEffect(() => { + const normalizedQuery = threadSearchQuery.trim(); + setActiveThreadSearchResultIndex(normalizedQuery.length > 0 ? 0 : -1); + }, [threadSearchQuery]); + useEffect(() => { + setActiveThreadSearchResultIndex((current) => { + if (visibleThreadSearchResults.length === 0) { + return -1; + } + if (current < 0) { + return 0; + } + return Math.min(current, visibleThreadSearchResults.length - 1); + }); + }, [visibleThreadSearchResults]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, @@ -1237,6 +1326,54 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const focusThreadSearchInput = useCallback((select = false) => { + window.requestAnimationFrame(() => { + const input = threadSearchInputRef.current; + if (!input) { + return; + } + input.focus(); + if (select) { + input.select(); + } + }); + }, []); + const openThreadSearch = useCallback( + (select = true) => { + threadSearchRestoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setThreadSearchOpen(true); + focusThreadSearchInput(select); + }, + [focusThreadSearchInput], + ); + const closeThreadSearch = useCallback(() => { + setThreadSearchOpen(false); + const focusTarget = threadSearchRestoreFocusRef.current; + threadSearchRestoreFocusRef.current = null; + if (focusTarget && focusTarget.isConnected) { + window.requestAnimationFrame(() => { + focusTarget.focus(); + }); + } + }, []); + const stepThreadSearch = useCallback( + (direction: 1 | -1) => { + if (visibleThreadSearchResults.length === 0) { + return; + } + setActiveThreadSearchResultIndex((current) => { + if (current < 0) { + return direction > 0 ? 0 : visibleThreadSearchResults.length - 1; + } + return ( + (current + direction + visibleThreadSearchResults.length) % + visibleThreadSearchResults.length + ); + }); + }, + [visibleThreadSearchResults.length], + ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { if (!activeThread) { @@ -2199,6 +2336,12 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || 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), @@ -2268,7 +2411,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + expandedImage, setTerminalOpen, + openThreadSearch, runProjectScript, splitTerminal, keybindings, @@ -3598,6 +3743,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Messages Wrapper */}
+ {threadSearchOpen && ( +
+ stepThreadSearch(1)} + onPrevious={() => stepThreadSearch(-1)} + onClose={closeThreadSearch} + /> +
+ )} {/* Messages */}
0} - isWorking={isWorking} + rows={timelineRows} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} @@ -3636,6 +3792,9 @@ export default function ChatView({ threadId }: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} + activeSearchRowId={activeThreadSearchRowId} + matchedSearchRowIds={matchedThreadSearchRowIds} + searchQuery={deferredThreadSearchQuery} />
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586..1bb6dea073 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,10 @@ +import { MessageId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + buildTimelineRows, + computeMessageDurationStart, + normalizeCompactToolLabel, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +148,147 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("buildTimelineRows", () => { + it("groups adjacent work entries, preserves plans, and appends the working row", () => { + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "message-1", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "work-1", + kind: "work", + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Ran command", + tone: "tool", + }, + }, + { + id: "work-2", + kind: "work", + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + label: "Updated file", + tone: "info", + }, + }, + { + id: "plan-1", + kind: "proposed-plan", + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Ship it", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:04Z", + }); + + expect(rows).toEqual([ + { + kind: "message", + id: "message-1", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "hello", + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + durationStart: "2026-01-01T00:00:00Z", + showCompletionDivider: false, + }, + { + kind: "work", + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + groupedEntries: [ + { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Ran command", + tone: "tool", + }, + { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + label: "Updated file", + tone: "info", + }, + ], + }, + { + kind: "proposed-plan", + id: "plan-1", + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan-1" as never, + turnId: null, + planMarkdown: "1. Ship it", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + { + kind: "working", + id: "working-indicator-row", + createdAt: "2026-01-01T00:00:04Z", + }, + ]); + }); + + it("marks the matching assistant row with the completion divider", () => { + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "assistant-1", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "Done", + createdAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:05Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: "assistant-1", + isWorking: false, + activeTurnStartedAt: null, + }); + + expect(rows[0]).toMatchObject({ + kind: "message", + id: "assistant-1", + showCompletionDivider: true, + }); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888e..8ab15da4fa 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,3 +1,5 @@ +import type { TimelineEntry } from "../../session-logic"; + export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; @@ -27,3 +29,100 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; +type TimelineWorkEntry = Extract["entry"]; + +export type TimelineRow = + | { + kind: "work"; + id: string; + createdAt: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + createdAt: string; + message: TimelineMessage; + durationStart: string; + showCompletionDivider: boolean; + } + | { + kind: "proposed-plan"; + id: string; + createdAt: string; + proposedPlan: TimelineProposedPlan; + } + | { kind: "working"; id: string; createdAt: string | null }; + +export function buildTimelineRows(input: { + timelineEntries: ReadonlyArray; + completionDividerBeforeEntryId: string | null; + isWorking: boolean; + activeTurnStartedAt: string | null; +}): TimelineRow[] { + const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const timelineEntry = input.timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + if (timelineEntry.kind === "proposed-plan") { + nextRows.push({ + kind: "proposed-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + proposedPlan: timelineEntry.proposedPlan, + }); + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + input.completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (input.isWorking) { + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + }); + } + + return nextRows; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..0777c31fc0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,14 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { buildTimelineRows } from "./MessagesTimeline.logic"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); function matchMedia() { return { @@ -45,36 +53,39 @@ beforeAll(() => { describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); const markup = renderToStaticMarkup( ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} completionSummary={null} turnDiffSummaryByAssistantMessageId={new Map()} nowIso="2026-03-17T19:12:30.000Z" @@ -89,6 +100,9 @@ describe("MessagesTimeline", () => { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + activeSearchRowId={null} + matchedSearchRowIds={new Set()} + searchQuery="" />, ); @@ -99,27 +113,30 @@ describe("MessagesTimeline", () => { it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); const markup = renderToStaticMarkup( { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + activeSearchRowId={null} + matchedSearchRowIds={new Set()} + searchQuery="" />, ); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders active inline search highlights without row-level emphasis", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "message-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-1"), + role: "user", + text: "Search target", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="message-1" + matchedSearchRowIds={new Set(["message-1"])} + searchQuery="Search" + />, + ); + + expect(markup).toContain('data-timeline-row-id="message-1"'); + expect(markup).toContain('data-search-match-state="active"'); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain(" { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "assistant-row-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-message-1"), + role: "assistant", + text: "The **highlight** should also appear in assistant markdown.", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="assistant-row-1" + matchedSearchRowIds={new Set(["assistant-row-1"])} + searchQuery="highlight" + />, + ); + + expect(markup).toContain('data-timeline-row-id="assistant-row-1"'); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlight<"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030ef..e8b88417f8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,7 +14,7 @@ import { type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; +import { formatElapsed } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -41,7 +41,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { normalizeCompactToolLabel, type TimelineRow } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -55,18 +55,16 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { renderHighlightedText } from "./threadSearchHighlight"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; interface MessagesTimelineProps { - hasMessages: boolean; - isWorking: boolean; + rows: ReadonlyArray; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; - timelineEntries: ReturnType; - completionDividerBeforeEntryId: string | null; completionSummary: string | null; turnDiffSummaryByAssistantMessageId: Map; nowIso: string; @@ -81,16 +79,16 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; + activeSearchRowId: string | null; + matchedSearchRowIds: ReadonlySet; + searchQuery: string; } export const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, - isWorking, + rows, activeTurnInProgress, activeTurnStartedAt, scrollContainer, - timelineEntries, - completionDividerBeforeEntryId, completionSummary, turnDiffSummaryByAssistantMessageId, nowIso, @@ -105,9 +103,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ resolvedTheme, timestampFormat, workspaceRoot, + activeSearchRowId, + matchedSearchRowIds, + searchQuery, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); + const isWorking = rows.some((row) => row.kind === "working"); + const hasRows = rows.some((row) => row.kind !== "working"); useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -132,72 +135,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [hasMessages, isWorking]); - - const rows = useMemo(() => { - const nextRows: TimelineRow[] = []; - const durationStartByMessageId = computeMessageDurationStart( - timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), - ); - - for (let index = 0; index < timelineEntries.length; index += 1) { - const timelineEntry = timelineEntries[index]; - if (!timelineEntry) { - continue; - } - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, - }); - index = cursor - 1; - continue; - } - - if (timelineEntry.kind === "proposed-plan") { - nextRows.push({ - kind: "proposed-plan", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - proposedPlan: timelineEntry.proposedPlan, - }); - continue; - } - - nextRows.push({ - kind: "message", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - message: timelineEntry.message, - durationStart: - durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, - showCompletionDivider: - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id, - }); - } - - if (isWorking) { - nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: activeTurnStartedAt, - }); - } - - return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + }, [rows]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -291,6 +229,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + const rowIndexById = useMemo( + () => new Map(rows.map((row, index) => [row.id, index] as const)), + [rows], + ); + const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< @@ -303,257 +246,326 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })); }, []); - const renderRowContent = (row: TimelineRow) => ( -
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; - - return ( -
- {showHeader && ( -
-

- {groupLabel} ({groupedEntries.length}) -

- {hasOverflow && ( - - )} -
- )} -
- {visibleEntries.map((workEntry) => ( - - ))} -
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); - const terminalContexts = displayedUserMessage.contexts; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {(displayedUserMessage.visibleText.trim().length > 0 || - terminalContexts.length > 0) && ( - - )} -
-
- {displayedUserMessage.copyText && ( - - )} - {canRevertAgentWork && ( - + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + )}
-

- {formatTimestamp(row.message.createdAt, timestampFormat)} -

+ )} +
+ {visibleEntries.map((workEntry) => ( + + ))}
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); - const allDirectoriesExpanded = - allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - • - - - )} -

-
- - -
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {renderHighlightedText( + image.name, + rowSearchQuery, + `user-image-name:${row.id}:${image.id}`, + { active: rowSearchActive }, + )} +
+ )} +
+ ), + )}
- 0 || + terminalContexts.length > 0) && ( + + )} +
+
+
+ {displayedUserMessage.copyText && ( + + )} + {canRevertAgentWork && ( + + )}
- ); - })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+

+ {formatTimestamp(row.message.createdAt, timestampFormat)} +

+
+
- - ); - })()} - - {row.kind === "proposed-plan" && ( -
- -
- )} + ); + })()} - {row.kind === "working" && ( -
-
- - - - - - - {row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working..."} - + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+
+ +
+ {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const changedFileCountLabel = String(checkpointFiles.length); + const allDirectoriesExpanded = + allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {hasNonZeroStat(summaryStat) && ( + <> + • + + + )} +

+
+ + +
+
+ +
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, + )} +

+
+ + ); + })()} + + {row.kind === "proposed-plan" && ( +
+
+ +
-
- )} -
- ); + )} + + {row.kind === "working" && ( +
+
+ + + + + + + {row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working..."} + +
+
+ )} +
+ ); + }; - if (!hasMessages && !isWorking) { + if (!hasRows && !isWorking) { return (

@@ -597,32 +609,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; -type TimelineProposedPlan = Extract["proposedPlan"]; -type TimelineWorkEntry = Extract["entry"]; -type TimelineRow = - | { - kind: "work"; - id: string; - createdAt: string; - groupedEntries: TimelineWorkEntry[]; - } - | { - kind: "message"; - id: string; - createdAt: string; - message: TimelineMessage; - durationStart: string; - showCompletionDivider: boolean; - } - | { - kind: "proposed-plan"; - id: string; - createdAt: string; - proposedPlan: TimelineProposedPlan; - } - | { kind: "working"; id: string; createdAt: string | null }; +type TimelineMessage = Extract["message"]; +type TimelineProposedPlan = Extract["proposedPlan"]; +type TimelineWorkEntry = Extract["groupedEntries"][number]; function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan): number { const estimatedLines = Math.max(1, Math.ceil(proposedPlan.planMarkdown.length / 72)); @@ -675,6 +664,8 @@ const UserMessageTerminalContextInlineLabel = memo( const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; + searchQuery: string; + searchActive: boolean; }) { if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( @@ -697,7 +688,12 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (matchIndex > cursor) { inlineNodes.push( - {props.text.slice(cursor, matchIndex)} + {renderHighlightedText( + props.text.slice(cursor, matchIndex), + props.searchQuery, + `user-terminal-before:${context.header}:${cursor}`, + { active: props.searchActive }, + )} , ); } @@ -714,7 +710,12 @@ const UserMessageBody = memo(function UserMessageBody(props: { if (cursor < props.text.length) { inlineNodes.push( - {props.text.slice(cursor)} + {renderHighlightedText( + props.text.slice(cursor), + props.searchQuery, + `user-terminal-rest:${cursor}`, + { active: props.searchActive }, + )} , ); } @@ -742,7 +743,13 @@ const UserMessageBody = memo(function UserMessageBody(props: { } if (props.text.length > 0) { - inlineNodes.push({props.text}); + inlineNodes.push( + + {renderHighlightedText(props.text, props.searchQuery, "user-terminal-inline-text", { + active: props.searchActive, + })} + , + ); } else if (inlinePrefix.length === 0) { return null; } @@ -760,7 +767,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { return (

-      {props.text}
+      {renderHighlightedText(props.text, props.searchQuery, "user-message-body", {
+        active: props.searchActive,
+      })}
     
); }); @@ -855,8 +864,10 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + searchQuery: string; + searchActive: boolean; }) { - const { workEntry } = props; + const { workEntry, searchActive, searchQuery } = props; const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -883,9 +894,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { title={displayText} > - {heading} + {renderHighlightedText(heading, searchQuery, `work-heading:${workEntry.id}`, { + active: searchActive, + })} - {preview && - {preview}} + {preview && ( + + {" - "} + {renderHighlightedText(preview, searchQuery, `work-preview:${workEntry.id}`, { + active: searchActive, + })} + + )}

@@ -897,7 +917,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { className="rounded-md border border-border/55 bg-background/75 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/75" title={filePath} > - {filePath} + {renderHighlightedText( + filePath, + searchQuery, + `work-file:${workEntry.id}:${filePath}`, + { + active: searchActive, + }, + )} ))} {(workEntry.changedFiles?.length ?? 0) > 4 && ( diff --git a/apps/web/src/components/chat/ProposedPlanCard.test.tsx b/apps/web/src/components/chat/ProposedPlanCard.test.tsx new file mode 100644 index 0000000000..510a5be5eb --- /dev/null +++ b/apps/web/src/components/chat/ProposedPlanCard.test.tsx @@ -0,0 +1,28 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); + +describe("ProposedPlanCard", () => { + it("highlights matches in the rendered plan title", async () => { + const { ProposedPlanCard } = await import("./ProposedPlanCard"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("Seed<"); + }); +}); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index c8956b9cfa..a2e9256c06 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -25,15 +25,20 @@ import { } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { readNativeApi } from "~/nativeApi"; +import { renderHighlightedText } from "./threadSearchHighlight"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, cwd, workspaceRoot, + searchQuery = "", + searchActive = false, }: { planMarkdown: string; cwd: string | undefined; workspaceRoot: string | undefined; + searchQuery?: string; + searchActive?: boolean; }) { const [expanded, setExpanded] = useState(false); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); @@ -118,7 +123,11 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
Plan -

{title}

+

+ {renderHighlightedText(title, searchQuery, `proposed-plan-title:${title}`, { + active: searchActive, + })} +

{canCollapse && !expanded ? ( - + ) : ( - + )} {canCollapse && !expanded ? (
diff --git a/apps/web/src/components/chat/ThreadSearchBar.browser.tsx b/apps/web/src/components/chat/ThreadSearchBar.browser.tsx new file mode 100644 index 0000000000..fce3c94ab8 --- /dev/null +++ b/apps/web/src/components/chat/ThreadSearchBar.browser.tsx @@ -0,0 +1,122 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ThreadSearchBar } from "./ThreadSearchBar"; + +function dispatchInputKey( + input: HTMLInputElement, + key: string, + options: { shiftKey?: boolean } = {}, +) { + input.dispatchEvent( + new KeyboardEvent("keydown", { + key, + shiftKey: options.shiftKey ?? false, + bubbles: true, + cancelable: true, + }), + ); +} + +async function mountBar(props?: { + query?: string; + resultCount?: number; + activeResultIndex?: number; +}) { + const host = document.createElement("div"); + document.body.append(host); + const onQueryChange = vi.fn(); + const onNext = vi.fn(); + const onPrevious = vi.fn(); + const onClose = vi.fn(); + const inputRef = { current: null as HTMLInputElement | null }; + const screen = await render( + , + { container: host }, + ); + + return { + inputRef, + onQueryChange, + onNext, + onPrevious, + onClose, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("ThreadSearchBar", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders count states for empty, missing, and active results", async () => { + const empty = await mountBar(); + try { + await expect + .element(page.getByTestId("thread-search-count")) + .toHaveTextContent("Type to search"); + } finally { + await empty.cleanup(); + } + + const noMatches = await mountBar({ query: "needle", resultCount: 0, activeResultIndex: -1 }); + try { + await expect.element(page.getByTestId("thread-search-count")).toHaveTextContent("No matches"); + } finally { + await noMatches.cleanup(); + } + + const active = await mountBar({ query: "needle", resultCount: 3, activeResultIndex: 1 }); + try { + await expect.element(page.getByTestId("thread-search-count")).toHaveTextContent("2 / 3"); + } finally { + await active.cleanup(); + } + }); + + it("routes Enter, Shift+Enter, and Escape to the expected callbacks", async () => { + const mounted = await mountBar({ query: "needle", resultCount: 2, activeResultIndex: 0 }); + + try { + const input = document.querySelector('[data-testid="thread-search-input"]'); + expect(input).toBeTruthy(); + input!.focus(); + dispatchInputKey(input!, "Enter"); + dispatchInputKey(input!, "Enter", { shiftKey: true }); + dispatchInputKey(input!, "Escape"); + + expect(mounted.onNext).toHaveBeenCalledTimes(1); + expect(mounted.onPrevious).toHaveBeenCalledTimes(1); + expect(mounted.onClose).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); + + it("disables navigation buttons when there are no results", async () => { + const mounted = await mountBar({ query: "needle", resultCount: 0, activeResultIndex: -1 }); + + try { + await expect.element(page.getByLabelText("Previous search result")).toBeDisabled(); + await expect.element(page.getByLabelText("Next search result")).toBeDisabled(); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/chat/ThreadSearchBar.tsx b/apps/web/src/components/chat/ThreadSearchBar.tsx new file mode 100644 index 0000000000..4529eca277 --- /dev/null +++ b/apps/web/src/components/chat/ThreadSearchBar.tsx @@ -0,0 +1,116 @@ +import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; +import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react"; + +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Kbd } from "../ui/kbd"; + +interface ThreadSearchBarProps { + query: string; + resultCount: number; + activeResultIndex: number; + inputRef: RefObject; + onQueryChange: (value: string) => void; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +} + +export function ThreadSearchBar({ + query, + resultCount, + activeResultIndex, + inputRef, + onQueryChange, + onNext, + onPrevious, + onClose, +}: ThreadSearchBarProps) { + const countLabel = + query.trim().length === 0 + ? "Type to search" + : resultCount === 0 + ? "No matches" + : `${Math.min(activeResultIndex + 1, resultCount)} / ${resultCount}`; + + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + onPrevious(); + return; + } + onNext(); + }; + + return ( +
+
+ +
+
+ onQueryChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Find in thread" + aria-label="Find in thread" + nativeInput + size="sm" + type="search" + data-testid="thread-search-input" + /> +
+
+ + {countLabel} + + Enter +
+
+ + + +
+
+ ); +} 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 0000000000..528b5964e8 --- /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 0000000000..203bf876b6 --- /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 0000000000..36d1a5fbee --- /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 0000000000..79b9958892 --- /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); + }; + }; +} From 7828e85e7f3ec10e44c55ee6244ee4259ee956e3 Mon Sep 17 00:00:00 2001 From: Xavier Date: Sat, 28 Mar 2026 18:42:43 -0300 Subject: [PATCH 2/6] Address thread search review feedback --- .../ChatView.threadSearch.browser.tsx | 83 +++++++++++++++++-- apps/web/src/components/ChatView.tsx | 3 + .../components/chat/MessagesTimeline.test.tsx | 62 ++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 8 +- .../src/components/chat/threadSearch.test.ts | 14 +++- apps/web/src/components/chat/threadSearch.ts | 23 +++-- 6 files changed, 175 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.threadSearch.browser.tsx b/apps/web/src/components/ChatView.threadSearch.browser.tsx index 5074fe23fc..d0ab43155b 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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 50093b722c..b650374c42 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2147,6 +2147,9 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); + setThreadSearchOpen(false); + setThreadSearchQuery(""); + setActiveThreadSearchResultIndex(-1); }, [threadId]); useEffect(() => { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0777c31fc0..5f9b78e70f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -215,6 +215,68 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("bg-warning/12"); }); + it("exposes hidden work log matches while searching overflowed groups", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const rows = buildTimelineRows({ + timelineEntries: [ + { + id: "work-entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Seeded hidden match", + tone: "info", + }, + }, + ...Array.from({ length: 6 }, (_, index) => ({ + id: `work-entry-${index + 2}`, + kind: "work" as const, + createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`, + entry: { + id: `work-${index + 2}`, + createdAt: `2026-03-17T19:12:${String(29 + index).padStart(2, "0")}.000Z`, + label: `Visible filler ${index + 1}`, + tone: "info" as const, + }, + })), + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + activeSearchRowId="work-entry-1" + matchedSearchRowIds={new Set(["work-entry-1"])} + searchQuery="Seeded" + />, + ); + + expect(markup).toContain("Seeded hidden match"); + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).not.toContain("Show 1 more"); + }); + it("renders assistant markdown search highlights", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const rows = buildTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e8b88417f8..3375f41cc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -135,7 +135,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => { observer.disconnect(); }; - }, [rows]); + }, [hasRows, isWorking]); const firstUnvirtualizedRowIndex = useMemo(() => { const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); @@ -304,8 +304,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const groupedEntries = row.groupedEntries; const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const searchExpanded = + rowSearchState !== null && rowSearchQuery.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; @@ -320,7 +322,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({

{groupLabel} ({groupedEntries.length})

- {hasOverflow && ( + {hasOverflow && !searchExpanded && (
-
- {canCollapse && !expanded ? ( +
+ {canCollapse && !showExpandedPlan ? ( )} - {canCollapse && !expanded ? ( + {canCollapse && !showExpandedPlan ? (
) : null}
- {canCollapse ? ( + {canCollapse && !searchExpanded ? (