From 2bb62bb8e4bc722063360c32ca62f2d0a8357bbb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 12:08:23 +0200 Subject: [PATCH] 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. --- package-lock.json | 13 + packages/app/e2e/browser/chat-find.spec.ts | 200 ++++++++++++++ .../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 | 23 +- packages/app/src/i18n/resources/ar.ts | 6 + packages/app/src/i18n/resources/en.ts | 5 + packages/app/src/i18n/resources/es.ts | 6 + packages/app/src/i18n/resources/fr.ts | 6 + packages/app/src/i18n/resources/ja.ts | 6 + packages/app/src/i18n/resources/ko.ts | 6 + packages/app/src/i18n/resources/pt-BR.ts | 6 + packages/app/src/i18n/resources/ru.ts | 6 + packages/app/src/i18n/resources/zh-CN.ts | 6 + packages/client/src/daemon-client.ts | 20 ++ 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, 1448 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 f8162e6c0d..26219d327b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39002,6 +39002,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", @@ -39024,6 +39025,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", @@ -39468,6 +39470,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..0a01da4a00 --- /dev/null +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -0,0 +1,200 @@ +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.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..ea971d4cb0 --- /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 && ( + + {state.error} + + + )} + + )} +
+ ); +} +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 eb68675580..a9a4a7cde4 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, @@ -731,16 +732,21 @@ const AgentStreamViewComponent = forwardRef - + + {(renderFullContent) => ( + + )} + ); }, @@ -1087,55 +1093,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: chatOutline.reportReadingPosition, - 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: chatOutline.reportReadingPosition, + 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 830eb265ea..c95765cfd9 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; @@ -1490,6 +1493,7 @@ function MarkdownListView({ } export const AssistantMessage = memo(function AssistantMessage({ + renderFullContent = false, occurrenceKey, message, timestamp: _timestamp, @@ -1501,10 +1505,15 @@ export const AssistantMessage = memo(function AssistantMessage({ }: AssistantMessageProps) { const { t } = useTranslation(); const markdownParser = useMemo(createAssistantMarkdownParser, []); - 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 revealedMessage = useRevealedText(renderedMessage.text, phase); + const pacedMessage = useRevealedText(renderedMessage.text, phase); + const revealedMessage = renderFullContent ? renderedMessage.text : pacedMessage; const fullMessageByteLength = useMemo( () => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null), [message, phase, renderedMessage.capped], @@ -1966,8 +1975,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 5e21b1a089..b40c4ad9a7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { paneFind: { + 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 acb8f94b74..3488151428 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,5 +1,10 @@ export const en = { paneFind: { + 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 2cf3568a6d..6f18669c9c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { paneFind: { + 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 440a843968..d89bc21896 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { paneFind: { + 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 eef67e031d..264a5062ab 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { paneFind: { + 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 fbe8970665..d4dd6ae26f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { paneFind: { + 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 b906b4d6e3..3bdce006a7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { paneFind: { + 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 76b066154a..dd68360f89 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { paneFind: { + 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 cc3dc52587..ef2c9a5c10 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -3,6 +3,12 @@ import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { paneFind: { + 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 e22534da35..e71951a007 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -576,6 +576,11 @@ export interface FetchAgentTimelineOptions { timeout?: number; } +export type AgentTimelineSearchPayload = Extract< + SessionOutboundMessage, + { type: "agent.timeline.search.response" } +>["payload"]; + export type AgentTimelinePromptIndexPayload = Extract< SessionOutboundMessage, { type: "agent.timeline.list_prompts.response" } @@ -2968,6 +2973,21 @@ export class DaemonClient { return { seq: payload.seq, epoch: payload.epoch }; } + async searchAgentTimeline( + agentId: string, + query: string, + cursor?: number, + ): 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 ed36eeb1ad..35258d8d71 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1812,6 +1812,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(), @@ -3151,6 +3159,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentTimelineSearchRequestMessageSchema, AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, @@ -4489,6 +4498,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({ @@ -6535,6 +6558,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 5913dfdb62..88b0631a62 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 ec6c4db2dd..f04ef72fef 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", "session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"], @@ -219,6 +220,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 c1e149ee5e..6f2641a1f3 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 { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { AgentRequests } from "./agent/requests/index.js"; import equal from "fast-deep-equal"; @@ -2328,6 +2329,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": @@ -7354,6 +7357,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,