From 80b4387bc61f3678de607167ec6ddbf50191d6aa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 17:54:01 +0200 Subject: [PATCH] Find text throughout chat history (#4765) * feat(app): add pane Find to file source * fix(app): float the pane Find widget and tighten its chrome Find pushed the first lines of the file down, ran 480px wide with 32px ghost buttons, and let the replacement input start left of the query input. Float the CodeMirror top panel out of flow so the widget overlays the top right of the pane, move the disclosure toggle into a gutter that spans both rows so the two fields share a leading rail, put the match count inside the query field, and drop the icon controls to the shared pane-toolbar chrome the file bar above already uses. * fix(app): keep the active match visible and drop the Find button The floating widget covered the active match whenever the match sat under it and the document was too short to scroll clear. CodeMirror's own panel scroll margin cannot reveal a first line, so the file integration now owns placement: the widget renders as an overlay beside the editor, the model measures it against the active match, and flips it to the opposite corner only when the current corner collides. Hysteresis keeps navigation from bouncing the widget between corners. Also removes the standalone Find button shown while Find is closed, per user direction. Cmd/Ctrl+F is the entry point. * fix(app): stop the Find placeholder from hiding Go to line Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor, including the Go to line dialog that Mod-Alt-g opens through the same search keymap. Hide only the inert Find placeholder panel instead, by its own class, and leave the shared panel container alone. * feat(app): find text throughout chat history Discover candidate transcript locations on the daemon, then reuse prompt navigation to load history and verify rendered occurrences on the client. Keep search state inside its own modules without changing timeline or task presentation contracts. * fix(app): show actionable chat search failures Keep transport error details in the search model while showing localized retry guidance. Use named client request options for timeline search. --- package-lock.json | 13 + packages/app/e2e/browser/chat-find.spec.ts | 205 ++++++++++++++ .../app/src/agent-stream/chat-find/index.tsx | 8 + .../src/agent-stream/chat-find/index.web.tsx | 166 ++++++++++++ .../src/agent-stream/chat-find/model.test.ts | 194 +++++++++++++ .../app/src/agent-stream/chat-find/model.ts | 256 ++++++++++++++++++ .../chat-find/ranges.browser.test.ts | 37 +++ .../src/agent-stream/chat-find/ranges.web.ts | 48 ++++ .../app/src/agent-stream/chat-find/types.ts | 18 ++ .../agent-stream/chat-find/viewport.web.ts | 89 ++++++ packages/app/src/agent-stream/strategy.ts | 5 +- .../agent-stream/use-scroll-to-message.web.ts | 30 +- packages/app/src/agent-stream/view.tsx | 136 ++++++---- packages/app/src/components/message.tsx | 22 +- packages/app/src/i18n/resources/ar.ts | 7 + packages/app/src/i18n/resources/en.ts | 6 + packages/app/src/i18n/resources/es.ts | 8 + packages/app/src/i18n/resources/fr.ts | 8 + packages/app/src/i18n/resources/ja.ts | 8 + packages/app/src/i18n/resources/ko.ts | 7 + packages/app/src/i18n/resources/pt-BR.ts | 8 + packages/app/src/i18n/resources/ru.ts | 8 + packages/app/src/i18n/resources/zh-CN.ts | 7 + packages/client/src/daemon-client.ts | 26 ++ packages/protocol/src/messages.ts | 24 ++ packages/server/package.json | 2 + .../server/agent/chat-search/index.test.ts | 67 +++++ .../src/server/agent/chat-search/index.ts | 69 +++++ .../authorization/operation-permissions.ts | 2 + packages/server/src/server/session.ts | 56 ++++ 30 files changed, 1472 insertions(+), 68 deletions(-) create mode 100644 packages/app/e2e/browser/chat-find.spec.ts create mode 100644 packages/app/src/agent-stream/chat-find/index.tsx create mode 100644 packages/app/src/agent-stream/chat-find/index.web.tsx create mode 100644 packages/app/src/agent-stream/chat-find/model.test.ts create mode 100644 packages/app/src/agent-stream/chat-find/model.ts create mode 100644 packages/app/src/agent-stream/chat-find/ranges.browser.test.ts create mode 100644 packages/app/src/agent-stream/chat-find/ranges.web.ts create mode 100644 packages/app/src/agent-stream/chat-find/types.ts create mode 100644 packages/app/src/agent-stream/chat-find/viewport.web.ts create mode 100644 packages/server/src/server/agent/chat-search/index.test.ts create mode 100644 packages/server/src/server/agent/chat-search/index.ts diff --git a/package-lock.json b/package-lock.json index a32cf6c543..2f12083f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39011,6 +39011,7 @@ "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", + "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.15", "openai": "^6.44.0", @@ -39033,6 +39034,7 @@ "devDependencies": { "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", + "@types/markdown-it": "^14.2.0", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", @@ -39477,6 +39479,17 @@ "cross-spawn": "7.0.6" } }, + "packages/server/node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "packages/server/node_modules/@vitest/expect": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts new file mode 100644 index 0000000000..5d94b5166a --- /dev/null +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -0,0 +1,205 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { + expectReconnectingToastGone, + expectReconnectingToastVisible, +} from "../support/helpers/workspace-ui"; +import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate"; + +const RESPONSE = + 'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell'; + +function query(page: Page) { + return page.getByRole("textbox", { name: "Find in pane", exact: true }); +} +function status(page: Page) { + return page.getByRole("status", { name: "Find matches" }); +} +async function searchChat(page: Page, text: string) { + await page.getByTestId("assistant-message").filter({ visible: true }).last().click(); + await page.keyboard.press("ControlOrMeta+f"); + await expect(query(page)).toBeFocused(); + await query(page).fill(text); +} +async function expectHighlight(page: Page, text: string) { + await expect + .poll(() => + page.evaluate(() => { + const entries = Array.from(CSS.highlights.entries()).filter(([name]) => + name.startsWith("paseo-chat-find-"), + ); + const texts: string[] = []; + for (const [, highlight] of entries) { + for (const range of highlight) texts.push(range.toString()); + } + return texts; + }), + ) + .toEqual([text]); + await expect + .poll(() => + page.evaluate(() => { + const entry = Array.from(CSS.highlights.entries()).find(([name]) => + name.startsWith("paseo-chat-find-"), + ); + const range = entry && Array.from(entry[1])[0]; + if (!(range instanceof Range)) return false; + const rect = range.getBoundingClientRect(); + const row = range.startContainer.parentElement?.closest( + '[data-testid="agent-chat-scroll"]', + ); + const viewport = row?.getBoundingClientRect(); + return ( + !!viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom + ); + }), + ) + .toBe(true); +} +async function nextMatch(page: Page) { + await query(page).press("Enter"); +} +async function previousMatch(page: Page) { + await query(page).press("Shift+Enter"); +} +async function closeFind(page: Page) { + await query(page).press("Escape"); + await expect(query(page)).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => + Array.from(CSS.highlights.keys()).filter((key) => key.startsWith("paseo-chat-find-")), + ), + ) + .toEqual([]); +} + +test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-", + title: "Chat Find", + featureValues: { mockAssistantResponse: RESPONSE }, + }); + try { + for (let index = 0; index < 45; index++) { + await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + const jumps = await trackPromptJumpRequests(page, agent.agentId); + await openAgentRoute(page, agent); + await expect(page.getByText("Prompt number 44", { exact: true })).toBeVisible(); + await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0); + await searchChat(page, "hello world"); + await expectHighlight(page, "hello world"); + await expect(status(page)).toHaveText("1 of 2 in message"); + await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") }); + expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true }); + await nextMatch(page); + await expect(status(page)).toHaveText("2 of 2 in message"); + await expectHighlight(page, "hello world"); + await previousMatch(page); + await expect(status(page)).toHaveText("1 of 2 in message"); + await query(page).fill("hello & world"); + await expectHighlight(page, "hello & world"); + await query(page).fill("a.b"); + await expectHighlight(page, "a.b"); + await query(page).fill("Prompt number 0"); + await expectHighlight(page, "Prompt number 0"); + await closeFind(page); + } finally { + await agent.cleanup(); + } +}); + +test("rejects invisible source matches and ignores a late reply after closing", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-cancel-", + title: "Chat Find cancellation", + initialPrompt: "Render the response", + featureValues: { mockAssistantResponse: RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expect(page.getByTestId("assistant-message")).toBeVisible(); + await searchChat(page, "hidden-destination"); + await expect(status(page)).toHaveText("No matches"); + await query(page).fill("first cell second cell"); + await expect(status(page)).toHaveText("No matches"); + gate.holdNextServerMessage("agent.timeline.search.response"); + await query(page).fill("hello world"); + await expect(status(page)).toHaveText("Searching…"); + await gate.waitForHeldServerMessage("agent.timeline.search.response"); + await closeFind(page); + gate.releaseHeldServerMessage("agent.timeline.search.response"); + await searchChat(page, "a.b"); + await expectHighlight(page, "a.b"); + await closeFind(page); + } finally { + await agent.cleanup(); + } +}); + +test("shows a disconnected search failure and recovers with Retry", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-retry-", + title: "Chat Find retry", + initialPrompt: "Render the response", + featureValues: { mockAssistantResponse: RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expect(page.getByTestId("assistant-message")).toBeVisible(); + await gate.drop(); + await expectReconnectingToastVisible(page); + await searchChat(page, "hello world"); + await expect(status(page)).toHaveText("Failed"); + await expect( + page.getByText("Could not search this chat. Check the host connection and retry.", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + gate.restore(); + await expectReconnectingToastGone(page); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expectHighlight(page, "hello world"); + } finally { + await agent.cleanup(); + } +}); + +test("finds text beyond the normal render cap and restores the cap when Find closes", async ({ + page, +}) => { + const tail = "oversized-tail-needle"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-cap-", + title: "Chat Find long message", + initialPrompt: "Render a long response", + featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentRoute(page, agent); + const message = page.getByTestId("assistant-message"); + await expect(message).toBeVisible(); + await expect(message).not.toContainText(tail); + await searchChat(page, tail); + await expectHighlight(page, tail); + await closeFind(page); + await expect(message).not.toContainText(tail); + await expect(message.getByTestId("assistant-message-capped-notice")).toBeVisible(); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/src/agent-stream/chat-find/index.tsx b/packages/app/src/agent-stream/chat-find/index.tsx new file mode 100644 index 0000000000..573f337cd2 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/index.tsx @@ -0,0 +1,8 @@ +import type { ChatFindProps, ChatFindExpansionProps } from "./types"; + +export function ChatFind({ children }: ChatFindProps) { + return children; +} +export function ChatFindExpansion({ children }: ChatFindExpansionProps) { + return children(false); +} diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx new file mode 100644 index 0000000000..ca9f7537b1 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -0,0 +1,166 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { View, Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { PaneFind, type PaneFindHandle } from "@/pane-find"; +import { Button } from "@/components/ui/button"; +import { usePaneFocus } from "@/panels/pane-context"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan"; +import { ChatFindModel } from "./model"; +import { createFindViewport } from "./viewport.web"; +import type { ChatFindProps, ChatFindExpansionProps } from "./types"; + +const Expansion = createContext(null); +const WIDGET_DATASET = { chatFindWidget: "true" }; +const ROOT_STYLE = { + display: "flex", + flex: 1, + minHeight: 0, + position: "relative", + outline: "none", +} as const; +let nextHighlightId = 0; + +export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) { + return children(useContext(Expansion) === itemId); +} + +export function ChatFind({ + agentId, + serverId, + epoch, + items, + viewportRef, + revealLoadedItem, + visibleItemIds, + children, +}: ChatFindProps) { + const { t } = useTranslation(); + const widget = useRef(null); + const root = useRef(null); + const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds }); + bindings.current = { viewportRef, revealLoadedItem, visibleItemIds }; + const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`); + const { isInteractive } = usePaneFocus(); + const active = useRetainedPanelActive(); + const model = useMemo( + () => + new ChatFindModel({ + search(query, cursor) { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.searchAgentTimeline({ agentId, query, cursor }); + }, + load(targetEpoch, seq) { + return getHostRuntimeStore().fetchAgentTimeline( + serverId, + agentId, + planTimelinePromptJump({ epoch: targetEpoch, seq }), + ); + }, + ...createFindViewport({ + getBindings: () => bindings.current, + getRoot: () => root.current, + highlightName, + }), + }), + [agentId, serverId, highlightName], + ); + const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + useEffect(() => { + model.updateHistory(epoch, items); + }, [model, epoch, items]); + useEffect(() => () => model.close(), [model]); + useEffect(() => { + if (!active) model.close(); + }, [active, model]); + useEffect(() => { + if (!active || !isInteractive) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + return; + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + model.open(); + widget.current?.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [active, isInteractive, model]); + const close = useCallback(() => { + model.close(); + root.current?.focus({ preventScroll: true }); + }, [model]); + let status = ""; + if (state.phase === "searching") status = t("paneFind.searching"); + else if (state.phase === "loading") status = t("paneFind.loading"); + else if (state.phase === "error") status = t("paneFind.failed"); + else if (state.query.trim()) + status = state.count + ? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count }) + : t("paneFind.noMatches"); + return ( +
+ + {children} + {state.open && ( + + 0} + onQueryChange={model.setQuery} + onNext={model.next} + onPrevious={model.previous} + onClose={close} + /> + {state.error && ( + + {t("paneFind.searchFailed")} + + + )} + + )} +
+ ); +} +const styles = StyleSheet.create((theme) => ({ + overlay: { + position: "absolute", + top: theme.spacing[2], + right: theme.spacing[3], + zIndex: 10, + maxWidth: "100%", + }, + error: { + flexDirection: "row", + alignItems: "center", + padding: theme.spacing[2], + backgroundColor: theme.colors.surface1, + }, + errorText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, flex: 1 }, + highlight: { backgroundColor: theme.colors.statusWarning, color: theme.colors.surface0 }, +})); diff --git a/packages/app/src/agent-stream/chat-find/model.test.ts b/packages/app/src/agent-stream/chat-find/model.test.ts new file mode 100644 index 0000000000..34a31698a7 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/model.test.ts @@ -0,0 +1,194 @@ +import { expect, it } from "vitest"; +import { hydrateStreamState, type StreamItem } from "@/types/stream"; +import { ChatFindModel, type ChatFindOperations } from "./model"; + +function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) { + return { + agentId: "agent", + requestId: "request", + epoch: "epoch", + locations, + nextCursor: null, + error: null, + }; +} +function message(seq: number, text = "target"): StreamItem { + return { + id: `row-${seq}`, + kind: "assistant_message", + text, + timestamp: new Date(0), + timelineCursor: { epoch: "epoch", seq }, + }; +} + +it("loads an older location and searches a response merged across suppressed tasks once", async () => { + const source = [ + { type: "assistant_message" as const, text: "first **target** " }, + { + type: "tool_call" as const, + callId: "task", + name: "TaskCreate", + status: "completed" as const, + detail: { type: "unknown" as const, input: {}, output: null }, + error: null, + }, + { type: "assistant_message" as const, text: "second target" }, + ]; + const history = hydrateStreamState( + source.map((item, index) => ({ + event: { type: "timeline" as const, provider: "claude" as const, item }, + timestamp: new Date(0), + timelineCursor: { epoch: "epoch", seq: index + 1 }, + })), + { source: "canonical" }, + ); + expect(history).toHaveLength(1); + const loaded: number[] = []; + const revealed: number[] = []; + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant" }, + { seq: 3, role: "assistant" }, + ]); + }, + async load(_epoch, seq) { + loaded.push(seq); + model.updateHistory("epoch", history); + }, + async reveal(_id, _query, occurrence) { + revealed.push(occurrence); + return { count: 2, occurrence: occurrence < 0 ? 1 : occurrence }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", []); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + model.next(); + await expect.poll(() => model.getSnapshot().occurrence).toBe(1); + model.next(); + await expect.poll(() => revealed).toEqual([0, 1, 0]); + model.previous(); + await expect.poll(() => model.getSnapshot().occurrence).toBe(1); + expect(loaded).toEqual([1]); + } finally { + model.close(); + } +}); + +it("skips a source-only candidate and highlights a displayed match", async () => { + const revealed: string[] = []; + const model = new ChatFindModel({ + async search() { + return response([ + { seq: 1, role: "assistant" }, + { seq: 2, role: "assistant" }, + ]); + }, + async load() { + throw new Error("Already loaded"); + }, + async reveal(id) { + revealed.push(id); + return { count: id === "row-1" ? 0 : 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1), message(2)]); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2"); + expect(revealed).toEqual(["row-1", "row-2"]); + expect(model.getSnapshot().phase).toBe("ready"); + } finally { + model.close(); + } +}); + +it("ignores replies after closing and does not navigate", async () => { + let finish!: (value: ReturnType) => void; + const calls: string[] = []; + const model = new ChatFindModel({ + search() { + calls.push("search"); + return new Promise((resolve) => { + finish = resolve; + }); + }, + async load() { + calls.push("load"); + }, + async reveal() { + calls.push("reveal"); + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + model.updateHistory("epoch", [message(1)]); + model.open(); + model.setQuery("target"); + await expect.poll(() => calls).toEqual(["search"]); + model.close(); + finish(response([{ seq: 1, role: "assistant" }])); + await Promise.resolve(); + expect(calls).toEqual(["search"]); + expect(model.getSnapshot()).toMatchObject({ open: false, phase: "idle" }); +}); + +it("retains a failed search for retry and distinguishes failure from zero matches", async () => { + let disconnected = true; + const operations: ChatFindOperations = { + async search() { + if (disconnected) throw new Error("Host disconnected"); + return response([]); + }, + async load() {}, + async reveal() { + return { count: 0, occurrence: 0 }; + }, + clear() {}, + }; + const model = new ChatFindModel(operations); + try { + model.updateHistory("epoch", []); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("error"); + expect(model.getSnapshot().error).toBe("Host disconnected"); + disconnected = false; + model.retry(); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + expect(model.getSnapshot()).toMatchObject({ count: 0, error: null }); + } finally { + model.close(); + } +}); + +it("can load a search location before the initial chat history has arrived", async () => { + const model = new ChatFindModel({ + async search() { + return response([{ seq: 1, role: "assistant" }]); + }, + async load() { + model.updateHistory("epoch", [message(1)]); + }, + async reveal() { + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory(null, []); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null }); + } finally { + model.close(); + } +}); diff --git a/packages/app/src/agent-stream/chat-find/model.ts b/packages/app/src/agent-stream/chat-find/model.ts new file mode 100644 index 0000000000..d1c351c598 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/model.ts @@ -0,0 +1,256 @@ +import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client"; +import type { StreamItem } from "@/types/stream"; + +type Location = AgentTimelineSearchPayload["locations"][number]; +interface Target { + id: string; + seq: number; + before: number; +} +interface Snapshot { + open: boolean; + query: string; + phase: "idle" | "searching" | "loading" | "ready" | "error"; + selectedItemId: string | null; + occurrence: number; + count: number; + error: string | null; +} +export interface ChatFindOperations { + search(query: string, cursor?: number): Promise; + load(epoch: string, seq: number): Promise; + reveal( + itemId: string, + query: string, + occurrence: number, + signal: AbortSignal, + ): Promise<{ occurrence: number; count: number }>; + clear(): void; +} + +export class ChatFindModel { + private state: Snapshot = { + open: false, + query: "", + phase: "idle", + selectedItemId: null, + occurrence: 0, + count: 0, + error: null, + }; + private listeners = new Set<() => void>(); + private historyListeners = new Set<() => void>(); + private items: StreamItem[] = []; + private epoch: string | null = null; + private resultEpoch: string | null = null; + private locations: Location[] = []; + private resolved = new Map(); + private current = -1; + private abort = new AbortController(); + private timer: ReturnType | undefined; + constructor(private operations: ChatFindOperations) {} + readonly getSnapshot = () => this.state; + readonly subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private publish(change: Partial) { + this.state = { ...this.state, ...change }; + this.listeners.forEach((listener) => listener()); + } + private cancel() { + this.abort.abort(); + this.abort = new AbortController(); + clearTimeout(this.timer); + this.operations.clear(); + } + readonly open = () => { + if (this.state.open) return; + this.publish({ open: true }); + if (this.state.query) this.setQuery(this.state.query); + }; + readonly close = () => { + this.cancel(); + this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null }); + }; + readonly setQuery = (query: string) => { + this.cancel(); + this.locations = []; + this.resultEpoch = null; + this.resolved.clear(); + this.current = -1; + this.publish({ + query, + phase: query.trim() ? "searching" : "idle", + selectedItemId: null, + occurrence: 0, + count: 0, + error: null, + }); + if (!query.trim()) return; + const signal = this.abort.signal; + this.timer = setTimeout(() => { + void this.search(signal); + }, 120); + }; + readonly retry = () => this.setQuery(this.state.query); + private fail(error: unknown, signal: AbortSignal) { + if (signal.aborted) return; + this.cancel(); + this.publish({ + phase: "error", + selectedItemId: null, + error: error instanceof Error ? error.message : String(error), + }); + } + private async search(signal: AbortSignal) { + try { + let cursor: number | undefined; + do { + const result = await this.operations.search(this.state.query, cursor); + if (signal.aborted) return; + const historyChanged = this.epoch !== null && result.epoch !== this.epoch; + const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch; + if (historyChanged || searchChanged) throw new Error("History changed; search again"); + this.resultEpoch = result.epoch; + this.locations.push(...result.locations); + cursor = result.nextCursor ?? undefined; + } while (cursor !== undefined); + await this.navigate(0, 1, signal); + } catch (error) { + this.fail(error, signal); + } + } + updateHistory(epoch: string | null, items: StreamItem[]) { + if ( + this.resultEpoch !== null && + epoch !== null && + epoch !== this.resultEpoch && + this.state.open + ) { + this.fail(new Error("History changed; search again"), this.abort.signal); + } + this.epoch = epoch; + this.items = items; + this.historyListeners.forEach((listener) => listener()); + } + private target(location: Location, exact: boolean): Target | null { + let before = 0; + for (const item of this.items) { + if (item.timelineCursor?.epoch !== this.resultEpoch) continue; + const seq = item.timelineCursor.seq; + if (seq < location.seq) { + before = Math.max(before, seq); + continue; + } + const sameRole = item.kind === `${location.role}_message`; + if (sameRole && (!exact || seq === location.seq)) return { id: item.id, seq, before }; + // A prompt is a hard boundary; never resolve an assistant hit in a later response. + if (item.kind === "user_message") return null; + } + return null; + } + private waitForTarget(location: Location, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const finish = (target?: Target) => { + clearTimeout(timeout); + this.historyListeners.delete(check); + signal.removeEventListener("abort", cancelled); + if (target) resolve(target); + else reject(new Error("Could not load this search location; retry")); + }; + const check = () => { + const target = this.target(location, false); + if (target) finish(target); + }; + const cancelled = () => finish(); + const timeout = setTimeout(() => finish(), 5000); + this.historyListeners.add(check); + signal.addEventListener("abort", cancelled, { once: true }); + if (signal.aborted) cancelled(); + else check(); + }); + } + private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) { + const epoch = this.resultEpoch; + if (epoch === null) return; + this.publish({ phase: "loading", error: null }); + let remaining = this.locations.length; + while (remaining-- > 0 && this.locations.length) { + index = (index + this.locations.length) % this.locations.length; + const location = this.locations[index]!; + const cached = this.resolved.get(location); + const stillLoaded = + cached && + this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq); + let target = stillLoaded ? cached : this.target(location, true); + if (!target) { + await this.operations.load(epoch, location.seq); + if (signal.aborted) return; + target = await this.waitForTarget(location, signal); + } + if (signal.aborted) return; + this.resolved.set(location, target); + // Multiple source hits may belong to the same displayed message. Resolve that + // here, using loaded client rows, without changing the timeline's data model. + this.locations = this.locations.filter( + (candidate) => + candidate === location || + candidate.role !== location.role || + candidate.seq <= target.before || + candidate.seq > target.seq, + ); + index = this.locations.indexOf(location); + this.publish({ selectedItemId: target.id }); + const result = await this.operations.reveal( + target.id, + this.state.query, + direction === 1 ? 0 : -1, + signal, + ); + if (signal.aborted) return; + if (result.count) { + this.current = index; + this.publish({ phase: "ready", ...result }); + return; + } + this.locations.splice(index, 1); + if (direction === -1) index--; + } + this.operations.clear(); + this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 }); + } + private async move(direction: 1 | -1) { + if (this.state.phase !== "ready" || !this.state.count) return; + this.cancel(); + const signal = this.abort.signal; + try { + const occurrence = this.state.occurrence + direction; + if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) { + this.publish({ phase: "loading" }); + const result = await this.operations.reveal( + this.state.selectedItemId, + this.state.query, + occurrence, + signal, + ); + if (signal.aborted) return; + if (result.count) { + this.publish({ phase: "ready", ...result }); + return; + } + } + await this.navigate(this.current + direction, direction, signal); + } catch (error) { + this.fail(error, signal); + } + } + readonly next = () => { + void this.move(1); + }; + readonly previous = () => { + void this.move(-1); + }; +} diff --git a/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts b/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts new file mode 100644 index 0000000000..64d4572ce3 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it } from "vitest"; +import { findRenderedMatches } from "./ranges.web"; + +afterEach(() => { + document.body.replaceChildren(); +}); +function content(html: string) { + const row = document.createElement("div"); + row.innerHTML = `
${html}
`; + document.body.append(row); + return row; +} +it("finds each actual occurrence across inline formatting with original Unicode offsets", () => { + const row = content( + '
İ😀hello world then hello world
', + ); + const matches = findRenderedMatches(row, "hello world"); + expect(matches.map((range) => range.toString())).toEqual(["hello world", "hello world"]); + expect(matches[0]!.startOffset).toBe(3); + expect(matches[0]!.compareBoundaryPoints(Range.START_TO_START, matches[1]!)).toBe(-1); +}); +it("ignores controls, keeps block boundaries, and preserves literal code punctuation", () => { + const row = content( + '
first
second
const a.ba.b
', + ); + expect(findRenderedMatches(row, "firstsecond")).toEqual([]); + expect(findRenderedMatches(row, "a.b").map((range) => range.toString())).toEqual(["a.b"]); +}); +it("matches whitespace across text nodes without inspecting hidden link destinations", () => { + const row = content( + '', + ); + expect(findRenderedMatches(row, "hidden")).toEqual([]); + expect(findRenderedMatches(row, "world line").map((range) => range.toString())).toEqual([ + "world\nline", + ]); +}); diff --git a/packages/app/src/agent-stream/chat-find/ranges.web.ts b/packages/app/src/agent-stream/chat-find/ranges.web.ts new file mode 100644 index 0000000000..c208ca7cdb --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts @@ -0,0 +1,48 @@ +const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"] + .map((tag) => `[data-paseo-markdown-tag="${tag}"]`) + .join(","); +const IGNORED = + '[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style'; + +/** Local offsets belong to the DOM that supplied the text, never to a host parser. */ +export function findRenderedMatches(row: HTMLElement, query: string): Range[] { + if (!query.trim()) return []; + const groups = new Map(); + for (const content of row.querySelectorAll('[data-message-text="true"]')) { + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; + const block = node.parentElement?.closest(BLOCKS) ?? content; + const texts = groups.get(block) ?? []; + texts.push(node); + groups.set(block, texts); + } + } + const pattern = query + .trim() + .split(/\s+/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\s+"); + const ranges: Range[] = []; + for (const texts of groups.values()) { + const points = texts.map((node) => ({ node, start: 0, end: 0 })); + let text = ""; + for (const point of points) { + point.start = text.length; + text += point.node.data; + point.end = text.length; + } + for (const match of text.matchAll(new RegExp(pattern, "giu"))) { + const start = points.find((point) => point.start <= match.index && point.end > match.index); + const endOffset = match.index + match[0].length; + const end = points.find((point) => point.start < endOffset && point.end >= endOffset); + if (!start || !end) continue; + const range = document.createRange(); + range.setStart(start.node, match.index - start.start); + range.setEnd(end.node, endOffset - end.start); + ranges.push(range); + } + } + return ranges; +} diff --git a/packages/app/src/agent-stream/chat-find/types.ts b/packages/app/src/agent-stream/chat-find/types.ts new file mode 100644 index 0000000000..215735735d --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/types.ts @@ -0,0 +1,18 @@ +import type { ReactNode, RefObject } from "react"; +import type { StreamItem } from "@/types/stream"; +import type { StreamViewportHandle } from "../strategy"; + +export interface ChatFindProps { + agentId: string; + serverId: string; + epoch: string | null; + items: StreamItem[]; + viewportRef: RefObject; + revealLoadedItem(itemId: string): boolean; + visibleItemIds: ReadonlySet; + children: ReactNode; +} +export interface ChatFindExpansionProps { + itemId: string; + children(renderFullContent: boolean): ReactNode; +} diff --git a/packages/app/src/agent-stream/chat-find/viewport.web.ts b/packages/app/src/agent-stream/chat-find/viewport.web.ts new file mode 100644 index 0000000000..d2c8b81487 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/viewport.web.ts @@ -0,0 +1,89 @@ +import type { ChatFindOperations } from "./model"; +import type { ChatFindProps } from "./types"; +import { findRenderedMatches } from "./ranges.web"; + +interface ViewportInput { + getBindings(): Pick; + getRoot(): HTMLElement | null; + highlightName: string; +} +export function createFindViewport({ + getBindings, + getRoot, + highlightName, +}: ViewportInput): Pick { + return { + clear() { + CSS.highlights.delete(highlightName); + }, + reveal(itemId, query, occurrence, signal) { + return new Promise((resolve, reject) => { + let frame = 0; + let started = false; + const deadline = performance.now() + 5000; + const cleanup = () => { + cancelAnimationFrame(frame); + signal.removeEventListener("abort", cancelled); + }; + const cancelled = () => { + cleanup(); + reject(new Error("Search cancelled")); + }; + const poll = () => { + if (signal.aborted) { + cancelled(); + return; + } + if (performance.now() >= deadline) { + cleanup(); + reject(new Error("Could not reveal this match; retry")); + return; + } + const current = getBindings(); + if (!current.visibleItemIds.has(itemId)) { + current.revealLoadedItem(itemId); + frame = requestAnimationFrame(poll); + return; + } + if (!started) { + current.viewportRef.current?.scrollToMessage?.(itemId); + started = true; + } + const row = getRoot()?.querySelector( + `[data-history-row-id="${CSS.escape(itemId)}"]`, + ); + if (!row || !row.getBoundingClientRect().height) { + frame = requestAnimationFrame(poll); + return; + } + const ranges = findRenderedMatches(row, query); + const index = + occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1); + const range = ranges[index]; + cleanup(); + if (!range) { + resolve({ occurrence: 0, count: 0 }); + return; + } + current.viewportRef.current?.scrollToMessage?.(itemId, { + signal, + targetTop(currentRow) { + const selected = findRenderedMatches(currentRow, query)[index]; + if (!selected) return null; + CSS.highlights.set(highlightName, new Highlight(selected)); + const widget = getRoot()?.querySelector( + '[data-chat-find-widget="true"]', + ); + const clearance = widget ? widget.getBoundingClientRect().height + 8 : 0; + return selected.getBoundingClientRect().top - clearance; + }, + }); + CSS.highlights.set(highlightName, new Highlight(range)); + resolve({ occurrence: index, count: ranges.length }); + }; + signal.addEventListener("abort", cancelled, { once: true }); + frame = requestAnimationFrame(poll); + }); + }, + }; +} diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 636ebe870a..5f0519c873 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -43,7 +43,10 @@ export interface StreamEdgeSlotProps { export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; - scrollToMessage?: (itemId: string) => void; + scrollToMessage?: ( + itemId: string, + occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null }, + ) => void; } export interface StreamSegmentRenderers { diff --git a/packages/app/src/agent-stream/use-scroll-to-message.web.ts b/packages/app/src/agent-stream/use-scroll-to-message.web.ts index 55aeae47a9..b207417ef8 100644 --- a/packages/app/src/agent-stream/use-scroll-to-message.web.ts +++ b/packages/app/src/agent-stream/use-scroll-to-message.web.ts @@ -1,5 +1,5 @@ import type React from "react"; -import { useCallback, useEffect, useLayoutEffect, useMemo } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { Virtualizer } from "@tanstack/react-virtual"; import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; @@ -32,6 +32,10 @@ export function useScrollToMessage({ setFollowOutput, onNearBottomChange, }: UseScrollToMessageInput) { + const occurrenceRef = useRef< + { signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined + >(undefined); + const removeAbortListener = useRef<(() => void) | null>(null); const settleController = useMemo( () => createPromptJumpSettleController({ @@ -41,7 +45,11 @@ export function useScrollToMessage({ const target = container?.querySelector( `[data-history-row-id="${CSS.escape(itemId)}"]`, ); - return target?.getBoundingClientRect().top ?? null; + const occurrence = occurrenceRef.current; + if (occurrence?.signal.aborted) return null; + if (!target) return null; + if (occurrence) return occurrence.targetTop(target); + return target.getBoundingClientRect().top; }, getContainerTop() { return scrollContainerRef.current?.getBoundingClientRect().top ?? 0; @@ -80,7 +88,13 @@ export function useScrollToMessage({ [scrollContainerRef], ); - useEffect(() => () => settleController.cancel(), [settleController]); + useEffect( + () => () => { + removeAbortListener.current?.(); + settleController.cancel(); + }, + [settleController], + ); useLayoutEffect(() => { if (!active) { settleController.cancel(); @@ -88,7 +102,15 @@ export function useScrollToMessage({ }, [active, settleController]); const scrollToMessage = useCallback( - (itemId: string) => { + ( + itemId: string, + occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null }, + ) => { + occurrenceRef.current = occurrence; + removeAbortListener.current?.(); + const cancel = () => settleController.cancel(); + occurrence?.signal.addEventListener("abort", cancel, { once: true }); + removeAbortListener.current = () => occurrence?.signal.removeEventListener("abort", cancel); if (!active) return; const container = scrollContainerRef.current; if (!container) return; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 669a435922..b7827686b9 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1,3 +1,4 @@ +import { ChatFind, ChatFindExpansion } from "@/agent-stream/chat-find"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { forwardRef, @@ -717,16 +718,21 @@ const AgentStreamViewComponent = forwardRef - + + {(renderFullContent) => ( + + )} + ); }, @@ -1081,55 +1087,69 @@ const AgentStreamViewComponent = forwardRef [...effectiveStreamItems, ...(effectiveStreamHead ?? [])], + [effectiveStreamItems, effectiveStreamHead], + ); return ( - - - - {streamRenderStrategy.render({ - agentId, - segments: renderModel.segments, - historyRowRevision, - liveHeadRowRevision: expandedToolCallGroupIds, - boundary, - renderers, - listEmptyComponent, - viewportRef, - routeBottomAnchorRequest, - isAuthoritativeHistoryReady, - onNearBottomChange: setIsNearBottom, - onReadingPositionChange: handleReadingPositionChange, - onNearHistoryStart: loadOlder, - isLoadingOlderHistory: isLoadingOlder, - hasOlderHistory: hasOlder, - olderHistoryProgressKey: progressKey, - scrollEnabled: streamScrollEnabled, - listStyle: stylesheet.list, - baseListContentContainerStyle: stylesheet.listContentContainer, - forwardListContentContainerStyle: stylesheet.forwardListContentContainer, - })} - - - {(!isNearBottom || isTimelineDetached) && ( - - - - - - - - )} - - + + + + + {streamRenderStrategy.render({ + agentId, + segments: renderModel.segments, + historyRowRevision, + liveHeadRowRevision: expandedToolCallGroupIds, + boundary, + renderers, + listEmptyComponent, + viewportRef, + routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + onNearBottomChange: setIsNearBottom, + onReadingPositionChange: handleReadingPositionChange, + onNearHistoryStart: loadOlder, + isLoadingOlderHistory: isLoadingOlder, + hasOlderHistory: hasOlder, + olderHistoryProgressKey: progressKey, + scrollEnabled: streamScrollEnabled, + listStyle: stylesheet.list, + baseListContentContainerStyle: stylesheet.listContentContainer, + forwardListContentContainerStyle: stylesheet.forwardListContentContainer, + })} + + + {(!isNearBottom || isTimelineDetached) && ( + + + + + + + + )} + + + ); }, ); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 89fbc6b007..5eca339013 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -421,6 +421,8 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage ); } +const MESSAGE_TEXT_DATASET = { messageText: "true" }; + export const UserMessage = memo(function UserMessage({ serverId, agentId, @@ -540,7 +542,7 @@ export const UserMessage = memo(function UserMessage({ ) : null} {hasText ? ( - + {message} ) : null} @@ -746,6 +748,7 @@ export const LiveElapsed = memo(function LiveElapsed({ }); interface AssistantMessageProps { + renderFullContent?: boolean; occurrenceKey: string; message: string; timestamp: number; @@ -1493,6 +1496,7 @@ function MarkdownListView({ } export const AssistantMessage = memo(function AssistantMessage({ + renderFullContent = false, occurrenceKey, message, timestamp: _timestamp, @@ -1508,11 +1512,15 @@ export const AssistantMessage = memo(function AssistantMessage({ () => createAssistantMarkdownParser({ streaming: true }), [], ); - const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]); + const renderedMessage = useMemo( + () => + renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message), + [message, renderFullContent], + ); // Paint a paced prefix while the turn is streaming so text arrives at a steady // rate instead of in whatever lumps the daemon's coalescing window produced. const stream = useWordStream(renderedMessage.text, phase); - const revealedMessage = stream.text; + const revealedMessage = renderFullContent ? renderedMessage.text : stream.text; const fullMessageByteLength = useMemo( () => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null), [message, phase, renderedMessage.capped], @@ -1978,8 +1986,12 @@ export const AssistantMessage = memo(function AssistantMessage({ const revealDataSet = useMemo( () => isRenderProfileEnabled() - ? { revealKey: occurrenceKey, revealLength: String(revealedMessage.length) } - : undefined, + ? { + ...MESSAGE_TEXT_DATASET, + revealKey: occurrenceKey, + revealLength: String(revealedMessage.length), + } + : MESSAGE_TEXT_DATASET, [occurrenceKey, revealedMessage.length], ); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5bff5a704b..0bbe1c78e3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { paneFind: { + searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", + searching: "جارٍ البحث…", + loading: "جارٍ التحميل…", + failed: "فشل", + retry: "إعادة المحاولة", + chatPosition: "{{current}} من {{total}} في الرسالة", + title: "بحث", placeholder: "بحث في اللوحة", close: "إغلاق البحث", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a5f73de0c8..e1efdc613d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,5 +1,11 @@ export const en = { paneFind: { + searchFailed: "Could not search this chat. Check the host connection and retry.", + searching: "Searching…", + loading: "Loading…", + failed: "Failed", + retry: "Retry", + chatPosition: "{{current}} of {{total}} in message", title: "Find", placeholder: "Find in pane", close: "Close Find", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0b298d59a3..ba66d68224 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { paneFind: { + searchFailed: + "No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.", + searching: "Buscando…", + loading: "Cargando…", + failed: "Error", + retry: "Reintentar", + chatPosition: "{{current}} de {{total}} en el mensaje", + title: "Buscar", placeholder: "Buscar en el panel", close: "Cerrar búsqueda", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d5fced76f6..b4ca2d7468 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { paneFind: { + searchFailed: + "Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.", + searching: "Recherche…", + loading: "Chargement…", + failed: "Échec", + retry: "Réessayer", + chatPosition: "{{current}} sur {{total}} dans le message", + title: "Rechercher", placeholder: "Rechercher dans le panneau", close: "Fermer la recherche", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4d6d6b6f35..4f1236698e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { paneFind: { + searchFailed: + "このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。", + searching: "検索中…", + loading: "読み込み中…", + failed: "失敗", + retry: "再試行", + chatPosition: "メッセージ内 {{current}} / {{total}}", + title: "検索", placeholder: "ペイン内を検索", close: "検索を閉じる", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f255969548..76d35a3ac4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { paneFind: { + searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", + searching: "검색 중…", + loading: "불러오는 중…", + failed: "실패", + retry: "다시 시도", + chatPosition: "메시지 내 {{current}} / {{total}}", + title: "찾기", placeholder: "패널에서 찾기", close: "찾기 닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 883c088759..88373ae583 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { paneFind: { + searchFailed: + "Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.", + searching: "Buscando…", + loading: "Carregando…", + failed: "Falhou", + retry: "Tentar novamente", + chatPosition: "{{current}} de {{total}} na mensagem", + title: "Buscar", placeholder: "Buscar no painel", close: "Fechar busca", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 51fa5f8af7..c77395d892 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -3,6 +3,14 @@ import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { paneFind: { + searchFailed: + "Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.", + searching: "Поиск…", + loading: "Загрузка…", + failed: "Ошибка", + retry: "Повторить", + chatPosition: "{{current}} из {{total}} в сообщении", + title: "Найти", placeholder: "Найти в панели", close: "Закрыть поиск", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 547da542a3..c47f65b153 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -3,6 +3,13 @@ import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { paneFind: { + searchFailed: "无法搜索此聊天。请检查主机连接后重试。", + searching: "搜索中…", + loading: "加载中…", + failed: "失败", + retry: "重试", + chatPosition: "消息内第 {{current}} 项,共 {{total}} 项", + title: "查找", placeholder: "在窗格中查找", close: "关闭查找", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index faa201511f..653ceb28ea 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -595,6 +595,17 @@ export interface FetchAgentTimelineOptions { timeout?: number; } +export interface AgentTimelineSearchOptions { + agentId: string; + query: string; + cursor?: number; +} + +export type AgentTimelineSearchPayload = Extract< + SessionOutboundMessage, + { type: "agent.timeline.search.response" } +>["payload"]; + export type AgentTimelinePromptIndexPayload = Extract< SessionOutboundMessage, { type: "agent.timeline.list_prompts.response" } @@ -3172,6 +3183,21 @@ export class DaemonClient { return { seq: payload.seq, epoch: payload.epoch }; } + async searchAgentTimeline({ + agentId, + query, + cursor, + }: AgentTimelineSearchOptions): Promise { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "agent.timeline.search.request", requestId, agentId, query, cursor }, + responseType: "agent.timeline.search.response", + }); + if (payload.error) throw new Error(payload.error); + return payload; + } + async listAgentTimelinePrompts( agentId: string, options: { requestId?: string; timeout?: number } = {}, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a44311d76b..517bec615b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1840,6 +1840,14 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ mergeWindow: z.boolean().optional(), }); +export const AgentTimelineSearchRequestMessageSchema = z.object({ + type: z.literal("agent.timeline.search.request"), + agentId: z.string(), + requestId: z.string(), + query: z.string(), + cursor: z.number().int().nonnegative().optional(), +}); + export const AgentTimelineListPromptsRequestMessageSchema = z.object({ type: z.literal("agent.timeline.list_prompts.request"), agentId: z.string(), @@ -3230,6 +3238,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentTimelineSearchRequestMessageSchema, AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, @@ -4591,6 +4600,20 @@ export const AgentTimelineReplacementMessageSchema = z.object({ }), }); +export const AgentTimelineSearchResponseMessageSchema = z.object({ + type: z.literal("agent.timeline.search.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + epoch: z.string(), + locations: z.array( + z.object({ seq: z.number().int().nonnegative(), role: z.enum(["user", "assistant"]) }), + ), + nextCursor: z.number().int().nonnegative().nullable(), + error: z.string().nullable(), + }), +}); + export const AgentTimelineListPromptsResponseMessageSchema = z.object({ type: z.literal("agent.timeline.list_prompts.response"), payload: z.object({ @@ -6732,6 +6755,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, AgentTimelineReplacementMessageSchema, + AgentTimelineSearchResponseMessageSchema, AgentTimelineListPromptsResponseMessageSchema, ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, diff --git a/packages/server/package.json b/packages/server/package.json index d9c30a4b8a..3f03281b72 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -88,6 +88,7 @@ "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", + "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.15", "openai": "^6.44.0", @@ -110,6 +111,7 @@ "devDependencies": { "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", + "@types/markdown-it": "^14.2.0", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", diff --git a/packages/server/src/server/agent/chat-search/index.test.ts b/packages/server/src/server/agent/chat-search/index.test.ts new file mode 100644 index 0000000000..2a9a9bf0e2 --- /dev/null +++ b/packages/server/src/server/agent/chat-search/index.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "vitest"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { searchTimeline } from "./index.js"; + +function rows(items: AgentTimelineItem[]) { + return items.map((item, index) => ({ item, seq: index + 1, timestamp: "2026-09-12T00:00:00Z" })); +} + +it("returns source locations for formatted assistant text and literal user text", async () => { + const result = await searchTimeline({ + rows: rows([ + { type: "user_message", text: "hello **world**" }, + { type: "assistant_message", text: "İ😀hello **world** and hello *world*" }, + { + type: "assistant_message", + text: "[hello & world](https://example.com)", + messageId: "separate", + }, + ]), + query: "hello world", + }); + expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null }); +}); + +it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => { + const result = await searchTimeline({ + rows: rows([ + { type: "assistant_message", text: "```ts\nconst value = 'a.b';\n```" }, + { type: "reasoning", text: "a.b" }, + { type: "todo", items: [{ text: "a.b", completed: false }] }, + { type: "user_message", text: "axb" }, + { type: "user_message", text: "a.b" }, + ]), + query: "a.b", + }); + expect(result.locations).toEqual([ + { seq: 1, role: "assistant" }, + { seq: 5, role: "user" }, + ]); +}); + +it("bounds results and resumes without losing locations", async () => { + const source = rows( + Array.from({ length: 202 }, () => ({ type: "user_message", text: "target" })), + ); + const first = await searchTimeline({ rows: source, query: "target" }); + expect(first.locations).toHaveLength(200); + expect(first.nextCursor).toBe(200); + const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! }); + expect(second).toEqual({ + locations: [ + { seq: 201, role: "user" }, + { seq: 202, role: "user" }, + ], + nextCursor: null, + }); +}); + +it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => { + const source = rows([{ type: "assistant_message", text: "hello & **world**\nnext line" }]); + expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([ + { seq: 1, role: "assistant" }, + ]); + expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([ + { seq: 1, role: "assistant" }, + ]); +}); diff --git a/packages/server/src/server/agent/chat-search/index.ts b/packages/server/src/server/agent/chat-search/index.ts new file mode 100644 index 0000000000..eee1cb57f5 --- /dev/null +++ b/packages/server/src/server/agent/chat-search/index.ts @@ -0,0 +1,69 @@ +import MarkdownIt from "markdown-it"; +import { setImmediate } from "node:timers/promises"; +import type { AgentTimelineRow } from "../agent-timeline-store-types.js"; +import { projectTimelineRows } from "../timeline-projection.js"; + +const markdown = new MarkdownIt({ html: false }); +const PAGE_SIZE = 200; +interface SearchLocation { + seq: number; + role: "user" | "assistant"; +} +interface SearchInput { + rows: readonly AgentTimelineRow[]; + query: string; + cursor?: number; +} + +// Discovery is deliberately approximate. Only the client can verify displayed text. +function searchableMarkdown(text: string): string { + return markdown + .parse(text, {}) + .map((token) => { + if (token.type === "inline") { + return (token.children ?? []) + .map((child) => { + if (child.type === "text" || child.type === "code_inline") return child.content; + if (child.type === "softbreak" || child.type === "hardbreak") return "\n"; + return ""; + }) + .join(""); + } + if (token.type === "fence" || token.type === "code_block") return token.content; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) { + const locations: SearchLocation[] = []; + if (!query.trim()) return { locations, nextCursor: null }; + const needle = normalizeSearchText(query); + let lastYield = performance.now(); + for (const entry of projectTimelineRows({ rows, mode: "projected" })) { + if (entry.seqEnd <= cursor) continue; + const item = entry.item; + if (item.type !== "user_message" && item.type !== "assistant_message") continue; + const raw = item.text.replace(/\r/g, ""); + const text = item.type === "assistant_message" ? searchableMarkdown(raw) : raw; + if (normalizeSearchText(raw).includes(needle) || normalizeSearchText(text).includes(needle)) { + if (locations.length === PAGE_SIZE) { + return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq }; + } + locations.push({ + seq: entry.seqEnd, + role: item.type === "user_message" ? "user" : "assistant", + }); + } + if (performance.now() - lastYield >= 8) { + await setImmediate(); + lastYield = performance.now(); + } + } + return { locations, nextCursor: null }; +} + +function normalizeSearchText(text: string): string { + return text.replace(/\s+/g, " ").trim().toLowerCase(); +} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 89d0d3f030..4dfb5f8b00 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -18,6 +18,7 @@ const INBOUND_PERMISSION = { "agent.skills.reconcile.request": "daemon.manage", "agent.skills.save_selection.request": "daemon.manage", "agent.skills.uninstall.request": "daemon.manage", + "agent.timeline.search.request": "workspace.read", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", "browser.host.register.request": ["workspace.write"], @@ -228,6 +229,7 @@ const OUTBOUND_PERMISSION = { "agent.skills.reconcile.response": "daemon.manage", "agent.skills.save_selection.response": "daemon.manage", "agent.skills.uninstall.response": "daemon.manage", + "agent.timeline.search.response": "workspace.read", "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "agent.timeline.replacement": ["workspace.read", "hub.execute"], diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a5daf44b52..753c163caf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; @@ -2584,6 +2585,8 @@ export class Session { return this.handleFetchAgentTimelineRequest(msg, source); case "agent.timeline.append.request": return this.handleAgentTimelineAppendRequest(msg); + case "agent.timeline.search.request": + return this.handleAgentTimelineSearchRequest(msg, source); case "agent.timeline.list_prompts.request": return this.handleAgentTimelineListPromptsRequest(msg, source); case "agent.provider_subagents.list.request": @@ -7763,6 +7766,59 @@ export class Session { }); } + private async handleAgentTimelineSearchRequest( + msg: Extract, + source?: object, + ): Promise { + try { + await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const rows = await this.agentManager.getTimelineRows(msg.agentId); + const { epoch } = this.agentManager.fetchTimeline(msg.agentId, { + direction: "tail", + limit: 1, + }); + const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor }); + if ( + this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }).epoch !== + epoch + ) { + throw new Error("History changed; search again"); + } + this.emitForSource( + { + type: "agent.timeline.search.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + epoch, + ...result, + error: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.timeline.search.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + epoch: "", + locations: [], + nextCursor: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + private async handleAgentTimelineListPromptsRequest( msg: Extract, source?: object,