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,