diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 0000000000..a4e36f58b1 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + }), +})); + +describe("ChatMarkdown", () => { + it("highlights assistant markdown text matches", async () => { + const { default: ChatMarkdown } = await import("./ChatMarkdown"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlight<"); + }); + + it("highlights fenced code matches without dropping the visible mark", async () => { + const { default: ChatMarkdown } = await import("./ChatMarkdown"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-thread-search-highlight="active"'); + expect(markup).toContain("highlightNeedle<"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..97a3a7dec9 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -23,6 +23,11 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { + createThreadSearchHighlightRehypePlugin, + renderHighlightedText, + textContainsThreadSearchMatch, +} from "./chat/threadSearchHighlight"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -49,6 +54,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; isStreaming?: boolean; + searchQuery?: string; + searchActive?: boolean; } const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -235,9 +242,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 }) { @@ -269,6 +286,25 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return
{children}
; } + if (textContainsThreadSearchMatch(codeBlock.code, searchQuery)) { + return ( + +
+                
+                  {renderHighlightedText(
+                    codeBlock.code,
+                    searchQuery,
+                    `markdown-code:${codeBlock.code}`,
+                    {
+                      active: searchActive,
+                    },
+                  )}
+                
+              
+
+ ); + } + return ( {children}}> @@ -285,12 +321,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [cwd, diffThemeName, isStreaming, searchActive, searchQuery], ); 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..e98860a6b8 --- /dev/null +++ b/apps/web/src/components/ChatView.threadSearch.browser.tsx @@ -0,0 +1,606 @@ +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 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); + +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, + }, + }, + { + 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, + }; +} + +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; + router: ReturnType; +}> { + 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 { + router, + 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 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("preserves the original focus restore target when Cmd/Ctrl+F is pressed again inside search", async () => { + const mounted = await mountApp(); + + try { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + dispatchThreadSearchShortcut(); + await waitForSearchInput(); + + dispatchThreadSearchShortcut(); + dispatchSearchInputKey("Escape"); + + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="thread-search-input"]')).toBeNull(); + expect(document.activeElement?.getAttribute("data-testid")).toBe("composer-editor"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("does not shift the thread layout when opened", async () => { + const mounted = await mountApp(); + + try { + await waitForAnyTimelineRow(); + const messagesScrollContainer = document.querySelector(".overscroll-y-contain"); + expect(messagesScrollContainer).toBeTruthy(); + const beforeTop = messagesScrollContainer!.getBoundingClientRect().top; + + dispatchThreadSearchShortcut(); + await waitForSearchInput(); + + await vi.waitFor(() => { + const afterTop = document + .querySelector(".overscroll-y-contain") + ?.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(); + } + }); + + 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 fbd332354a..129d1f2287 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,7 +23,15 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { truncate } from "@t3tools/shared/String"; -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,15 @@ import { } from "./chat/composerProviderRegistry"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { ThreadSearchBar } from "./chat/ThreadSearchBar"; +import { + buildThreadSearchIndex, + createEmptyThreadSearchLookupState, + findThreadSearchLookupState, + type ThreadSearchIndexEntry, + type ThreadSearchLookupState, + type ThreadSearchResult, +} from "./chat/threadSearch"; import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -189,6 +207,9 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_THREAD_SEARCH_INDEX: readonly ThreadSearchIndexEntry[] = []; +const EMPTY_THREAD_SEARCH_RESULTS: readonly ThreadSearchResult[] = []; +const EMPTY_MATCHED_THREAD_SEARCH_ROW_IDS = new Set(); function formatOutgoingPrompt(params: { provider: ProviderKind; @@ -206,6 +227,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 +359,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 +439,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 +1052,70 @@ 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( + () => (threadSearchOpen ? buildThreadSearchIndex(timelineRows) : EMPTY_THREAD_SEARCH_INDEX), + [threadSearchOpen, timelineRows], + ); + const threadSearchLookupStateRef = useRef( + createEmptyThreadSearchLookupState(threadSearchIndex), + ); + const threadSearchLookupState = useMemo( + () => + findThreadSearchLookupState( + threadSearchIndex, + threadSearchOpen ? deferredThreadSearchQuery : "", + threadSearchLookupStateRef.current, + ), + [deferredThreadSearchQuery, threadSearchIndex, threadSearchOpen], + ); + useEffect(() => { + threadSearchLookupStateRef.current = threadSearchLookupState; + }, [threadSearchLookupState]); + const threadSearchResults = useMemo( + () => threadSearchLookupState.results, + [threadSearchLookupState], + ); + const visibleThreadSearchResults = useMemo( + () => (threadSearchOpen ? threadSearchResults : EMPTY_THREAD_SEARCH_RESULTS), + [threadSearchOpen, threadSearchResults], + ); + const matchedThreadSearchRowIds = useMemo( + () => + visibleThreadSearchResults.length > 0 + ? new Set(visibleThreadSearchResults.map((result) => result.rowId)) + : EMPTY_MATCHED_THREAD_SEARCH_ROW_IDS, + [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 }, @@ -1255,6 +1362,57 @@ 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) => { + 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); + 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) { @@ -2028,6 +2186,9 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); + setThreadSearchOpen(false); + setThreadSearchQuery(""); + setActiveThreadSearchResultIndex(-1); }, [threadId]); useEffect(() => { @@ -2217,6 +2378,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), @@ -2286,7 +2453,9 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + expandedImage, setTerminalOpen, + openThreadSearch, runProjectScript, splitTerminal, keybindings, @@ -3619,6 +3788,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} @@ -3657,6 +3837,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..4875907063 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,141 @@ 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"]; +export type TimelineWorkEntry = Extract["entry"]; + +function capitalizePhrase(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +export function renderableWorkEntryHeading(workEntry: TimelineWorkEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); + } + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); +} + +export function renderableWorkEntryPreview( + workEntry: Pick, +): string | null { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; +} + +export function renderableWorkEntryChangedFiles( + workEntry: Pick, +): string[] { + const changedFiles = workEntry.changedFiles ?? []; + if (changedFiles.length === 0) { + return []; + } + if (!workEntry.command && !workEntry.detail) { + return []; + } + return changedFiles.slice(0, 4); +} + +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..b03851a2ca 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="" />, ); @@ -97,29 +111,90 @@ describe("MessagesTimeline", () => { expect(markup).toContain("yoo what's "); }); - it("renders context compaction entries in the normal work log", async () => { + it("highlights rendered terminal chip labels during search", 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-chip-search"), + role: "user", + text: [ + "check this @terminal-1:1-5", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | echoed output", + "", + ].join("\n"), + 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="entry-1" + matchedSearchRowIds={new Set(["entry-1"])} + searchQuery="Terminal 1 lines 1-5" + />, + ); + + expect(markup).toContain("Terminal 1 lines 1-5"); + expect(markup).toContain('data-thread-search-highlight="active"'); + }); + + it("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", - entry: { - id: "work-1", - createdAt: "2026-03-17T19:12:28.000Z", - label: "Context compacted", - tone: "info", - }, + label: "Context compacted", + tone: "info", }, - ]} - completionDividerBeforeEntryId={null} + }, + ], + 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("