diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 19d84d2f50..b31a3fd9ee 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -29,9 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`. - **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not. - **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. -- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native - blocks and parses only the growing last block during append. Splitting in the reducer discards - paragraph separators and exposes fragments to plugin callbacks, which need the whole source text. +- **Markdown blocks belong to presentation, and every assistant message is a block group.** + `agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same + way whether it arrived streaming or as fetched history. Splitting in the reducer instead would + discard paragraph separators and expose fragments to plugin callbacks, which need the whole source + text. Live-head work stays cheap by parsing only the growing last block during append, retaining + completed blocks by object identity, and caching the split per source item so a tail change does + not re-split history. History was once left whole to keep cross-block Markdown context; that gave + one message two shapes depending on how it arrived, and message-addressed features only worked on + one of them. +- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with + `blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two + being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its + message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything + addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts + the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for + React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image + occurrence keys. - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index d80401deb4..5d4838f133 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -1,4 +1,4 @@ -import type { BrowserContext } from "@playwright/test"; +import type { BrowserContext, Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise { await context.grantPermissions(["clipboard-read", "clipboard-write"]); } +/** + * The rows of one assistant message. A message renders one row per Markdown block, so + * every locator and every text walk below covers the group, not a single block. + */ +function assistantMessageBlocks(page: Page): Locator { + return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") }); +} + async function selectStructuredClipboardFixture(page: Page): Promise { await selectAssistantTextRange(page, "Direct matches:", "ready"); } async function selectAssistantElement(page: Page, selector: string): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); + const assistantMessage = assistantMessageBlocks(page); await assistantMessage.locator(selector).evaluate((element) => { const selection = window.getSelection(); const range = document.createRange(); @@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker( itemText: string, endText: string, ): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); + const assistantMessage = assistantMessageBlocks(page); const item = assistantMessage .locator(`[data-paseo-markdown-tag="${listTag}"]`) .filter({ hasText: itemText }) @@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText( tag: "code" | "strong", text: string, ): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); + const assistantMessage = assistantMessageBlocks(page); await assistantMessage .locator(`[data-paseo-markdown-tag="${tag}"]`) .filter({ hasText: text }) @@ -184,12 +186,20 @@ async function selectAssistantTextRange( startText: string, endText: string, ): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); - await assistantMessage.evaluate( - (element, selectedRange) => { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + await assistantMessageBlocks(page).evaluateAll( + (blocks, selectedRange) => { + // The message's text nodes in reading order, across every block row. + const nodes: Text[] = []; + for (const block of blocks) { + const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let found = blockWalker.nextNode(); + while (found) { + nodes.push(found as Text); + found = blockWalker.nextNode(); + } + } + let cursor = 0; + const walker = { nextNode: () => nodes[cursor++] ?? null }; let startNode: Node | null = null; let startOffset = -1; let endNode: Node | null = null; @@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines( startText: string, endText: string, ): Promise { - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); - await assistantMessage.evaluate( - (element, selectedRange) => { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + await assistantMessageBlocks(page).evaluateAll( + (blocks, selectedRange) => { + // The message's text nodes in reading order, across every block row. + const nodes: Text[] = []; + for (const block of blocks) { + const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let found = blockWalker.nextNode(); + while (found) { + nodes.push(found as Text); + found = blockWalker.nextNode(); + } + } + let cursor = 0; + const walker = { nextNode: () => nodes[cursor++] ?? null }; let startNode: Node | null = null; let startOffset = -1; let textNode = walker.nextNode(); @@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as ); await openAgentRoute(page, agent); - const assistantMessage = page.getByTestId("assistant-message").filter({ - hasText: "Direct matches:", - }); + const assistantMessage = assistantMessageBlocks(page); for (const [tag, text] of [ ["strong", "strong prose"], ["em", "emphasized prose"], diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index a9f1fbdff5..dd00f93216 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -1,6 +1,10 @@ import type { TestInfo } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "../support/helpers/mock-agent"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectReconnectingToastGone, @@ -19,19 +23,38 @@ import { openCommandCenter, closeCommandCenter } from "../support/helpers/comman const MAC_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; +const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" "); +const STREAMED_RESPONSE = [ + "First paragraph with alpha-needle and pair-needle inside.", + "Second paragraph with beta-needle and pair-needle inside.", + `Third paragraph ${FILLER}`, +].join("\n\n"); + 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'; +// One message far taller than the viewport, so its first and last paragraph rows are +// never in the virtualizer's window at the same time. +const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" "); +const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => { + if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`; + if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`; + return `Paragraph ${index}. ${PARAGRAPH_PADDING}`; +}).join("\n\n"); + 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) { +async function openChatFind(page: Page) { await page.getByTestId("assistant-message").filter({ visible: true }).last().click(); await page.keyboard.press("ControlOrMeta+f"); await expect(query(page)).toBeFocused(); +} +async function searchChat(page: Page, text: string) { + await openChatFind(page); await query(page).fill(text); } async function expectHighlight(page: Page, text: string) { @@ -75,6 +98,15 @@ async function nextMatch(page: Page) { async function previousMatch(page: Page) { await query(page).press("Shift+Enter"); } +async function expectFindStatus(page: Page, search: string, matches: string) { + await query(page).fill(search); + await expect(status(page)).toHaveText(matches, { timeout: 15_000 }); +} +async function expectNextMatch(page: Page, matches: string, highlighted: string) { + await nextMatch(page); + await expect(status(page)).toHaveText(matches); + await expectHighlight(page, highlighted); +} async function closeFind(page: Page) { await query(page).press("Escape"); await expect(query(page)).toHaveCount(0); @@ -140,7 +172,7 @@ test("rejects invisible source matches and ignores a late reply after closing", 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 expect(page.getByTestId("assistant-message").first()).toBeVisible(); await searchChat(page, "hidden-destination"); await expect(status(page)).toHaveText("No matches"); await query(page).fill("first cell second cell"); @@ -170,7 +202,7 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag 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 expect(page.getByTestId("assistant-message").first()).toBeVisible(); await gate.drop(); await expectReconnectingToastVisible(page); await searchChat(page, "hello world"); @@ -190,6 +222,104 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag } }); +interface StreamedReplyChat extends MockAgentWorkspace { + /** Resolves once the streamed reply has finished arriving. */ + replyFinished(): Promise; +} + +/** + * Opens the chat first, then streams a multi-block reply into it and waits for a + * later block, so the block holding the early needles is no longer the growing row. + */ +async function openChatStreamingMultiBlockReply(page: Page): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-live-blocks-", + title: "Chat Find live blocks", + featureValues: { + mockStreamingAssistantResponse: STREAMED_RESPONSE, + mockStreamingAssistantIntervalMs: 60, + }, + }); + try { + await openAgentRoute(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer"); + await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({ + timeout: 30_000, + }); + return { + ...agent, + replyFinished: async () => { + await agent.client.waitForFinish(agent.agentId, 60_000); + }, + }; + } catch (error) { + await agent.cleanup(); + throw error; + } +} + +test("finds a hit in a message that streamed while the chat was open", async ({ page }) => { + test.setTimeout(120_000); + const chat = await openChatStreamingMultiBlockReply(page); + try { + await openChatFind(page); + // The only hit is in the first paragraph, which is no longer the row that grows. + await expectFindStatus(page, "alpha-needle", "1 of 1 in message"); + await chat.replyFinished(); + await expectFindStatus(page, "beta-needle", "1 of 1 in message"); + // One message, two Markdown blocks, one count across both. + await expectFindStatus(page, "pair-needle", "1 of 2 in message"); + await expectNextMatch(page, "2 of 2 in message", "pair-needle"); + await closeFind(page); + } finally { + await chat.cleanup(); + } +}); + +/** + * Opens a chat whose newest reply is a single message far taller than the viewport, + * behind enough older turns that the oldest one is not hydrated yet. + */ +async function openChatWithVirtualizedTallMessage(page: Page): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-virtualized-", + title: "Chat Find virtualized message", + featureValues: { mockAssistantResponse: TALL_RESPONSE }, + }); + try { + for (let index = 0; index < 6; index++) { + await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await openAgentRoute(page, agent); + await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible(); + await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0); + return agent; + } catch (error) { + await agent.cleanup(); + throw error; + } +} + +// Every block row of the selected message must be mounted for Find to see it, whatever +// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing +// from the count and Next walks off to the next message instead. +test("counts every occurrence of a virtualized message and steps between them", async ({ + page, +}) => { + test.setTimeout(120_000); + const chat = await openChatWithVirtualizedTallMessage(page); + try { + await openChatFind(page); + await expectFindStatus(page, "span-needle", "1 of 2 in message"); + await expectHighlight(page, "span-needle"); + await expectNextMatch(page, "2 of 2 in message", "span-needle"); + await closeFind(page); + } finally { + await chat.cleanup(); + } +}); + test("finds text beyond the normal render cap and restores the cap when Find closes", async ({ page, }) => { @@ -198,7 +328,8 @@ test("finds text beyond the normal render cap and restores the cap when Find clo repoPrefix: "chat-find-cap-", title: "Chat Find long message", initialPrompt: "Render a long response", - featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` }, + // One paragraph, so the needle is past the render cap of the row that holds it. + featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` }, }); try { await agent.client.waitForFinish(agent.agentId, 15_000); diff --git a/packages/app/src/agent-stream/chat-find/index.tsx b/packages/app/src/agent-stream/chat-find/index.tsx index 573f337cd2..daa78baea2 100644 --- a/packages/app/src/agent-stream/chat-find/index.tsx +++ b/packages/app/src/agent-stream/chat-find/index.tsx @@ -6,3 +6,7 @@ export function ChatFind({ children }: ChatFindProps) { export function ChatFindExpansion({ children }: ChatFindExpansionProps) { return children(false); } +/** Native has no chat find, so no message is ever selected by one. */ +export function useChatFindSelectedMessageId(): string | null { + return null; +} diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx index bbe9a69930..7a36416948 100644 --- a/packages/app/src/agent-stream/chat-find/index.web.tsx +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -19,11 +19,16 @@ 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 { ChatFindModel, type ChatFindFailure } from "./model"; import { createFindViewport } from "./viewport.web"; import type { ChatFindProps, ChatFindExpansionProps } from "./types"; -const Expansion = createContext(null); +const SelectedMessage = createContext(null); +const FAILURE_TEXT: Record = { + connection: "paneFind.connectionFailure", + historyChanged: "paneFind.historyChangedFailure", + reveal: "paneFind.revealFailure", +}; const WIDGET_DATASET = { chatFindWidget: "true" }; const ROOT_STYLE = { display: "flex", @@ -34,8 +39,17 @@ const ROOT_STYLE = { } as const; let nextHighlightId = 0; -export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) { - return children(useContext(Expansion) === itemId); +/** + * The message chat find has landed on, for surfaces that must keep it addressable: + * the row that lifts its render cap, and the web virtualizer that has to mount every + * block row of it so the whole message can be counted and highlighted. + */ +export function useChatFindSelectedMessageId(): string | null { + return useContext(SelectedMessage); +} + +export function ChatFindExpansion({ messageId, children }: ChatFindExpansionProps) { + return children(useChatFindSelectedMessageId() === messageId); } export function ChatFind({ @@ -44,15 +58,15 @@ export function ChatFind({ epoch, items, viewportRef, - revealLoadedItem, - visibleItemIds, + revealLoadedMessage, + visibleMessageIds, 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 bindings = useRef({ viewportRef, revealLoadedMessage, visibleMessageIds }); + bindings.current = { viewportRef, revealLoadedMessage, visibleMessageIds }; const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`); const { isInteractive } = usePaneFocus(); const active = useRetainedPanelActive(); @@ -117,7 +131,7 @@ export function ChatFind({ return (
- {children} + {children} {state.open && ( - {state.error && ( + {state.failure && ( - {t("paneFind.searchFailed")} + {t(FAILURE_TEXT[state.failure])} diff --git a/packages/app/src/agent-stream/chat-find/model.test.ts b/packages/app/src/agent-stream/chat-find/model.test.ts index 34a31698a7..d1e784e19f 100644 --- a/packages/app/src/agent-stream/chat-find/model.test.ts +++ b/packages/app/src/agent-stream/chat-find/model.test.ts @@ -159,11 +159,60 @@ it("retains a failed search for retry and distinguishes failure from zero matche model.open(); model.setQuery("target"); await expect.poll(() => model.getSnapshot().phase).toBe("error"); - expect(model.getSnapshot().error).toBe("Host disconnected"); + expect(model.getSnapshot().failure).toBe("connection"); disconnected = false; model.retry(); await expect.poll(() => model.getSnapshot().phase).toBe("ready"); - expect(model.getSnapshot()).toMatchObject({ count: 0, error: null }); + expect(model.getSnapshot()).toMatchObject({ count: 0, failure: null }); + } finally { + model.close(); + } +}); + +// `load` is a host RPC like `search`, so its failure is a connection failure and the +// widget must say so rather than blaming the reveal. +it("reports a failed timeline load as a connection failure", async () => { + const model = new ChatFindModel({ + async search() { + return response([{ seq: 1, role: "assistant" }]); + }, + async load() { + throw new Error("Host disconnected"); + }, + async reveal() { + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", []); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("error"); + expect(model.getSnapshot().failure).toBe("connection"); + } finally { + model.close(); + } +}); + +it("reports a search result that outlived its epoch as a changed history", async () => { + const model = new ChatFindModel({ + async search() { + return response([{ seq: 1, role: "assistant" }]); + }, + async load() {}, + async reveal() { + return { count: 1, occurrence: 0 }; + }, + clear() {}, + }); + try { + model.updateHistory("epoch", [message(1)]); + model.open(); + model.setQuery("target"); + await expect.poll(() => model.getSnapshot().phase).toBe("ready"); + model.updateHistory("epoch-2", [message(1)]); + expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" }); } finally { model.close(); } @@ -187,7 +236,7 @@ it("can load a search location before the initial chat history has arrived", asy model.open(); model.setQuery("target"); await expect.poll(() => model.getSnapshot().phase).toBe("ready"); - expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null }); + expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, failure: 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 index d1c351c598..0cd9f39af7 100644 --- a/packages/app/src/agent-stream/chat-find/model.ts +++ b/packages/app/src/agent-stream/chat-find/model.ts @@ -7,6 +7,30 @@ interface Target { seq: number; before: number; } +/** + * Why a search stopped, so the widget can say something true. `connection` is the + * host call itself failing, `historyChanged` is the timeline moving under a result + * set, and `reveal` is a located match that never made it onto the screen. + */ +export type ChatFindFailure = "connection" | "historyChanged" | "reveal"; + +class ChatFindFailureError extends Error { + constructor( + readonly failure: ChatFindFailure, + options?: { cause: unknown }, + ) { + super(`Chat find failed: ${failure}`, options); + } +} + +function tagged(failure: ChatFindFailure, operation: Promise): Promise { + return operation.catch((cause: unknown) => { + throw cause instanceof ChatFindFailureError + ? cause + : new ChatFindFailureError(failure, { cause }); + }); +} + interface Snapshot { open: boolean; query: string; @@ -14,13 +38,13 @@ interface Snapshot { selectedItemId: string | null; occurrence: number; count: number; - error: string | null; + failure: ChatFindFailure | null; } export interface ChatFindOperations { search(query: string, cursor?: number): Promise; load(epoch: string, seq: number): Promise; reveal( - itemId: string, + messageId: string, query: string, occurrence: number, signal: AbortSignal, @@ -36,7 +60,7 @@ export class ChatFindModel { selectedItemId: null, occurrence: 0, count: 0, - error: null, + failure: null, }; private listeners = new Set<() => void>(); private historyListeners = new Set<() => void>(); @@ -73,7 +97,7 @@ export class ChatFindModel { }; readonly close = () => { this.cancel(); - this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null }); + this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, failure: null }); }; readonly setQuery = (query: string) => { this.cancel(); @@ -87,7 +111,7 @@ export class ChatFindModel { selectedItemId: null, occurrence: 0, count: 0, - error: null, + failure: null, }); if (!query.trim()) return; const signal = this.abort.signal; @@ -102,18 +126,21 @@ export class ChatFindModel { this.publish({ phase: "error", selectedItemId: null, - error: error instanceof Error ? error.message : String(error), + // Every operation call site is tagged, so an untagged failure is a bug here + // rather than a known outcome; `connection` is the least misleading thing to + // say about one, and its copy already asks the reader to check and retry. + failure: error instanceof ChatFindFailureError ? error.failure : "connection", }); } private async search(signal: AbortSignal) { try { let cursor: number | undefined; do { - const result = await this.operations.search(this.state.query, cursor); + const result = await tagged("connection", 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"); + if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged"); this.resultEpoch = result.epoch; this.locations.push(...result.locations); cursor = result.nextCursor ?? undefined; @@ -130,7 +157,7 @@ export class ChatFindModel { epoch !== this.resultEpoch && this.state.open ) { - this.fail(new Error("History changed; search again"), this.abort.signal); + this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal); } this.epoch = epoch; this.items = items; @@ -159,7 +186,7 @@ export class ChatFindModel { this.historyListeners.delete(check); signal.removeEventListener("abort", cancelled); if (target) resolve(target); - else reject(new Error("Could not load this search location; retry")); + else reject(new ChatFindFailureError("reveal")); }; const check = () => { const target = this.target(location, false); @@ -176,7 +203,7 @@ export class ChatFindModel { private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) { const epoch = this.resultEpoch; if (epoch === null) return; - this.publish({ phase: "loading", error: null }); + this.publish({ phase: "loading", failure: null }); let remaining = this.locations.length; while (remaining-- > 0 && this.locations.length) { index = (index + this.locations.length) % this.locations.length; @@ -187,7 +214,7 @@ export class ChatFindModel { 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); + await tagged("connection", this.operations.load(epoch, location.seq)); if (signal.aborted) return; target = await this.waitForTarget(location, signal); } @@ -204,11 +231,9 @@ export class ChatFindModel { ); 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, + const result = await tagged( + "reveal", + this.operations.reveal(target.id, this.state.query, direction === 1 ? 0 : -1, signal), ); if (signal.aborted) return; if (result.count) { @@ -230,11 +255,9 @@ export class ChatFindModel { 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, + const result = await tagged( + "reveal", + this.operations.reveal(this.state.selectedItemId, this.state.query, occurrence, signal), ); if (signal.aborted) return; if (result.count) { 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 index 64d4572ce3..c1d84c1b42 100644 --- a/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts +++ b/packages/app/src/agent-stream/chat-find/ranges.browser.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it } from "vitest"; -import { findRenderedMatches } from "./ranges.web"; +import { findMessageMatches, findRenderedMatches } from "./ranges.web"; afterEach(() => { document.body.replaceChildren(); @@ -10,6 +10,36 @@ function content(html: string) { document.body.append(row); return row; } +function message(messageId: string, ...blocks: string[]) { + const root = document.createElement("div"); + for (const block of blocks) { + const row = document.createElement("div"); + row.dataset.messageId = messageId; + row.innerHTML = `
${block}
`; + root.append(row); + } + document.body.append(root); + return root; +} +it("collects a message's occurrences across its block rows in row order", () => { + const root = message("message-1", "alpha one", "beta", "alpha two"); + const other = document.createElement("div"); + other.dataset.messageId = "message-2"; + other.innerHTML = + '
alpha three
'; + root.append(other); + expect(findMessageMatches(root, "message-1", "alpha").map((range) => range.toString())).toEqual([ + "alpha", + "alpha", + ]); + expect( + findMessageMatches(root, "message-1", "alpha").map( + (range) => range.startContainer.parentElement?.textContent, + ), + ).toEqual(["alpha one", "alpha two"]); + expect(findMessageMatches(root, "message-2", "alpha")).toHaveLength(1); + expect(findMessageMatches(null, "message-1", "alpha")).toEqual([]); +}); it("finds each actual occurrence across inline formatting with original Unicode offsets", () => { const row = content( '
İ😀hello world then hello world
', diff --git a/packages/app/src/agent-stream/chat-find/ranges.web.ts b/packages/app/src/agent-stream/chat-find/ranges.web.ts index c208ca7cdb..91ed7bff6a 100644 --- a/packages/app/src/agent-stream/chat-find/ranges.web.ts +++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts @@ -4,6 +4,23 @@ const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6" const IGNORED = '[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style'; +/** The rows of one message, in reading order. Each is one Markdown block. */ +export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] { + if (!root) return []; + return Array.from( + root.querySelectorAll(`[data-message-id="${CSS.escape(messageId)}"]`), + ); +} + +/** Occurrences in one message, ordered by row and then by position inside the row. */ +export function findMessageMatches( + root: HTMLElement | null, + messageId: string, + query: string, +): Range[] { + return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query)); +} + /** 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 []; diff --git a/packages/app/src/agent-stream/chat-find/types.ts b/packages/app/src/agent-stream/chat-find/types.ts index 215735735d..cce58be96e 100644 --- a/packages/app/src/agent-stream/chat-find/types.ts +++ b/packages/app/src/agent-stream/chat-find/types.ts @@ -8,11 +8,11 @@ export interface ChatFindProps { epoch: string | null; items: StreamItem[]; viewportRef: RefObject; - revealLoadedItem(itemId: string): boolean; - visibleItemIds: ReadonlySet; + revealLoadedMessage(messageId: string): boolean; + visibleMessageIds: ReadonlySet; children: ReactNode; } export interface ChatFindExpansionProps { - itemId: string; + messageId: string; children(renderFullContent: boolean): ReactNode; } diff --git a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts new file mode 100644 index 0000000000..2bd1e291b4 --- /dev/null +++ b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, it } from "vitest"; +import { createFindViewport } from "./viewport.web"; +import type { ChatFindProps } from "./types"; +import type { StreamViewportHandle } from "../strategy"; + +interface Capture { + occurrence?: { signal: AbortSignal; targetTop(): number | null }; +} + +function transcript(messageId: string, blocks: string[]): HTMLElement { + const root = document.createElement("div"); + for (const [index, text] of blocks.entries()) { + const row = document.createElement("div"); + row.setAttribute("data-history-row-id", `${messageId}:block:${index}`); + row.setAttribute("data-message-id", messageId); + row.innerHTML = `
${text}
`; + root.append(row); + } + document.body.append(root); + return root; +} + +function viewport(root: HTMLElement, capture: Capture) { + const handle: StreamViewportHandle = { + scrollToBottom() {}, + prepareForViewportChange() {}, + scrollToMessage(_messageId, occurrence) { + if (occurrence) capture.occurrence = occurrence; + }, + }; + const bindings: Pick = + { + viewportRef: { current: handle }, + revealLoadedMessage: () => false, + visibleMessageIds: new Set(["message-1"]), + }; + return createFindViewport({ + getBindings: () => bindings, + getRoot: () => root, + highlightName: "paseo-chat-find-test", + }); +} + +afterEach(() => { + CSS.highlights.delete("paseo-chat-find-test"); + document.body.replaceChildren(); +}); + +it("counts occurrences across every block row of the message", async () => { + const root = transcript("message-1", ["needle one needle", "no hits", "needle three"]); + const capture: Capture = {}; + const found = await viewport(root, capture).reveal( + "message-1", + "needle", + 2, + new AbortController().signal, + ); + expect(found).toEqual({ occurrence: 2, count: 3 }); +}); + +// The settle loop asks for the target position every frame while the viewport moves, +// and rows mount and unmount as it does. A flat index into the message would name a +// different occurrence each time the row set changed; the chosen row owns it instead. +it("keeps the chosen occurrence when an earlier row unmounts mid-scroll", async () => { + const root = transcript("message-1", ["needle one needle", "needle three"]); + const capture: Capture = {}; + const found = await viewport(root, capture).reveal( + "message-1", + "needle", + 2, + new AbortController().signal, + ); + expect(found).toEqual({ occurrence: 2, count: 3 }); + + const settled = capture.occurrence?.targetTop(); + expect(settled).toBeTypeOf("number"); + expect(highlightedText()).toEqual(["needle"]); + expect(highlightedRow()).toBe("message-1:block:1"); + + root.firstElementChild?.remove(); + expect(capture.occurrence?.targetTop()).toBeTypeOf("number"); + expect(highlightedRow()).toBe("message-1:block:1"); +}); + +function highlightedRanges(): Range[] { + const highlight = CSS.highlights.get("paseo-chat-find-test"); + return highlight ? Array.from(highlight).filter((range) => range instanceof Range) : []; +} + +function highlightedText(): string[] { + return highlightedRanges().map((range) => range.toString()); +} + +function highlightedRow(): string | undefined { + const row = + highlightedRanges()[0]?.startContainer.parentElement?.closest( + "[data-history-row-id]", + ); + return row?.dataset.historyRowId; +} diff --git a/packages/app/src/agent-stream/chat-find/viewport.web.ts b/packages/app/src/agent-stream/chat-find/viewport.web.ts index d2c8b81487..6bbb3a704b 100644 --- a/packages/app/src/agent-stream/chat-find/viewport.web.ts +++ b/packages/app/src/agent-stream/chat-find/viewport.web.ts @@ -1,12 +1,17 @@ import type { ChatFindOperations } from "./model"; import type { ChatFindProps } from "./types"; -import { findRenderedMatches } from "./ranges.web"; +import { findMessageMatches, findMessageRows, findRenderedMatches } from "./ranges.web"; interface ViewportInput { - getBindings(): Pick; + getBindings(): Pick; getRoot(): HTMLElement | null; highlightName: string; } + +function rowOf(range: Range): HTMLElement | null { + return range.startContainer.parentElement?.closest("[data-history-row-id]") ?? null; +} + export function createFindViewport({ getBindings, getRoot, @@ -16,7 +21,9 @@ export function createFindViewport({ clear() { CSS.highlights.delete(highlightName); }, - reveal(itemId, query, occurrence, signal) { + // A message renders as one row per Markdown block, so an occurrence can sit in + // any of them and the chosen one decides where the viewport lands. + reveal(messageId, query, occurrence, signal) { return new Promise((resolve, reject) => { let frame = 0; let started = false; @@ -40,23 +47,21 @@ export function createFindViewport({ return; } const current = getBindings(); - if (!current.visibleItemIds.has(itemId)) { - current.revealLoadedItem(itemId); + if (!current.visibleMessageIds.has(messageId)) { + current.revealLoadedMessage(messageId); frame = requestAnimationFrame(poll); return; } if (!started) { - current.viewportRef.current?.scrollToMessage?.(itemId); + current.viewportRef.current?.scrollToMessage?.(messageId); started = true; } - const row = getRoot()?.querySelector( - `[data-history-row-id="${CSS.escape(itemId)}"]`, - ); - if (!row || !row.getBoundingClientRect().height) { + const rows = findMessageRows(getRoot(), messageId); + if (!rows.some((row) => row.getBoundingClientRect().height)) { frame = requestAnimationFrame(poll); return; } - const ranges = findRenderedMatches(row, query); + const ranges = findMessageMatches(getRoot(), messageId, query); const index = occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1); const range = ranges[index]; @@ -65,10 +70,18 @@ export function createFindViewport({ resolve({ occurrence: 0, count: 0 }); return; } - current.viewportRef.current?.scrollToMessage?.(itemId, { + // The settle loop re-resolves the range every frame because scrolling + // invalidates it. Re-counting the whole message would let a row mounting or + // unmounting mid-scroll shift which occurrence a fixed index names, so the + // occurrence is pinned to its own row for the rest of this reveal. + const row = rowOf(range); + const indexInRow = ranges + .slice(0, index) + .filter((earlier) => rowOf(earlier) === row).length; + current.viewportRef.current?.scrollToMessage?.(messageId, { signal, - targetTop(currentRow) { - const selected = findRenderedMatches(currentRow, query)[index]; + targetTop() { + const selected = row ? findRenderedMatches(row, query)[indexInRow] : undefined; if (!selected) return null; CSS.highlights.set(highlightName, new Highlight(selected)); const widget = getRoot()?.querySelector( diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 6b64d992eb..d84db755e9 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -153,7 +153,7 @@ describe("useChatOutline", () => { timelineEpoch, enabled, viewportRef, - visibleItemIds: new Set(), + visibleMessageIds: new Set(), onJumpError: vi.fn(), }), { @@ -236,7 +236,7 @@ describe("useChatOutline", () => { prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }], }); const scrollToMessage = vi.fn(); - const revealLoadedItem = vi.fn(() => true); + const revealLoadedMessage = vi.fn(() => true); const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), @@ -253,7 +253,7 @@ describe("useChatOutline", () => { }, ]; const { result, rerender } = renderHook( - ({ visibleItemIds }) => + ({ visibleMessageIds }) => useChatOutline({ agentId: "agent-1", serverId: "server-1", @@ -263,18 +263,18 @@ describe("useChatOutline", () => { enabled: true, viewportRef, onJumpError: vi.fn(), - visibleItemIds, - revealLoadedItem, + visibleMessageIds, + revealLoadedMessage, }), - { initialProps: { visibleItemIds: new Set() } }, + { initialProps: { visibleMessageIds: new Set() } }, ); await waitFor(() => expect(result.current.prompts).toHaveLength(1)); await act(async () => result.current.jumpToPrompt(1)); - expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt"); + expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt"); expect(scrollToMessage).not.toHaveBeenCalled(); - rerender({ visibleItemIds: new Set(["older-prompt"]) }); + rerender({ visibleMessageIds: new Set(["older-prompt"]) }); await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt")); }); @@ -286,7 +286,7 @@ describe("useChatOutline", () => { const fetch = deferred(); runtime.fetchAgentTimeline.mockReturnValue(fetch.promise); const scrollToMessage = vi.fn(); - const revealLoadedItem = vi.fn(() => true); + const revealLoadedMessage = vi.fn(() => true); const viewportRef = { current: { scrollToBottom: vi.fn(), @@ -302,7 +302,7 @@ describe("useChatOutline", () => { timelineCursor: { epoch: "epoch-1", seq: 1 }, }; const { result, rerender } = renderHook( - ({ tail, visibleItemIds }) => + ({ tail, visibleMessageIds }) => useChatOutline({ agentId: "agent-1", serverId: "server-1", @@ -312,13 +312,13 @@ describe("useChatOutline", () => { enabled: true, viewportRef, onJumpError: vi.fn(), - visibleItemIds, - revealLoadedItem, + visibleMessageIds, + revealLoadedMessage, }), { initialProps: { tail: [] as (typeof fetchedPrompt)[], - visibleItemIds: new Set(), + visibleMessageIds: new Set(), }, }, ); @@ -327,11 +327,11 @@ describe("useChatOutline", () => { act(() => result.current.jumpToPrompt(1)); await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce()); - rerender({ tail: [fetchedPrompt], visibleItemIds: new Set() }); - await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id)); + rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set() }); + await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id)); expect(scrollToMessage).not.toHaveBeenCalled(); - rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) }); + rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) }); await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce()); expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id); await act(async () => fetch.resolve()); @@ -355,8 +355,8 @@ describe("useChatOutline", () => { timestamp: new Date(2), timelineCursor: { epoch: "epoch-1", seq: 2 }, }; - const revealLoadedItem = vi.fn(); - revealLoadedItem.mockReturnValue(false); + const revealLoadedMessage = vi.fn(); + revealLoadedMessage.mockReturnValue(false); const { result } = renderHook(() => useChatOutline({ agentId: "agent-1", @@ -367,8 +367,8 @@ describe("useChatOutline", () => { enabled: true, viewportRef: { current: viewport }, onJumpError: vi.fn(), - visibleItemIds: new Set([livePrompt.id]), - revealLoadedItem, + visibleMessageIds: new Set([livePrompt.id]), + revealLoadedMessage, }), ); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index 39938a4b9c..b59e670a5d 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -33,8 +33,8 @@ export interface UseChatOutlineInput { enabled: boolean; viewportRef: RefObject; onJumpError: () => void; - visibleItemIds?: ReadonlySet; - revealLoadedItem?: (itemId: string) => boolean; + visibleMessageIds?: ReadonlySet; + revealLoadedMessage?: (messageId: string) => boolean; } export interface ChatOutline { @@ -53,8 +53,8 @@ export function useChatOutline({ enabled, viewportRef, onJumpError, - visibleItemIds, - revealLoadedItem, + visibleMessageIds, + revealLoadedMessage, }: UseChatOutlineInput): ChatOutline { const [index, setIndex] = useState(null); const [pendingJump, setPendingJump] = useState(null); @@ -136,8 +136,8 @@ export function useChatOutline({ const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq); if (target) { if (pendingJump.hasScrolled) return; - if (visibleItemIds?.has(target.id) === false) { - revealLoadedItem?.(target.id); + if (visibleMessageIds?.has(target.id) === false) { + revealLoadedMessage?.(target.id); return; } viewportRef.current?.scrollToMessage?.(target.id); @@ -148,7 +148,7 @@ export function useChatOutline({ return; } if (pendingJump.fetchSettled) setPendingJump(null); - }, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]); + }, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]); const jumpToPrompt = useCallback( (seq: number) => { @@ -156,7 +156,7 @@ export function useChatOutline({ setPendingJump(null); const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq); if (loaded) { - if (revealLoadedItem?.(loaded.id)) { + if (revealLoadedMessage?.(loaded.id)) { const requestId = nextJumpRequestIdRef.current; setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false }); return; @@ -180,7 +180,7 @@ export function useChatOutline({ }); }); }, - [agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef], + [agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef], ); return { prompts, activePrompt, jumpToPrompt, reportReadingPosition }; diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index c5b33b4c40..786cd5192b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -210,21 +210,113 @@ describe("stream presentation through installed plugins", () => { expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]); }); - it("leaves fetched native Markdown intact, including cross-paragraph references", () => { + // One rendering path: a fetched message is the same block group as a streamed one, + // which is what lets find, scroll-to-message and history reveal address it by id. + // A link reference definition stays with the paragraph that uses it, so the split + // never leaves an empty row behind and the reference still resolves. + it("splits an assistant message into the same blocks through history and through the live head", () => { const source = hydrateStreamState([ { - event: assistant("[Link][docs]\n\n[docs]: https://example.com"), + event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."), timestamp: new Date(1000), }, ]); + const messageId = source[0]!.id; + const present = (placement: "tail" | "head") => + rows( + createStreamPresentation()({ + ...presentationOptions, + tail: placement === "tail" ? source : [], + head: placement === "head" ? source : [], + transform: undefined, + }), + ); + expect(present("tail")).toMatchObject([ + { + id: `${messageId}:block:0`, + blockGroupId: messageId, + blockIndex: 0, + text: "[Link][docs]\n\n[docs]: https://example.com", + }, + { + id: `${messageId}:block:1`, + blockGroupId: messageId, + blockIndex: 1, + text: "Closing paragraph.", + }, + ]); + const blocks = (items: StreamItem[]) => + items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]); + expect(blocks(present("head"))).toEqual(blocks(present("tail"))); + }); + + // Rows are addressed by id from outside presentation, so the same text has to land on + // the same ids whether the reader watched it arrive or opened the chat afterwards. + it("gives a streamed message the block ids its hydrated text would have had", () => { + const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph."; + const harness = streamHarness(); + for (let end = 8; end < text.length; end += 8) { + harness.send(assistant(text.slice(end - 8, end))); + } + harness.send(assistant(text.slice(text.length - (text.length % 8 || 8)))); + const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" })); + const hydrated = rows( + createStreamPresentation()({ + ...presentationOptions, + tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]), + head: [], + transform: undefined, + }), + ); + expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id)); + expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual( + hydrated.map((item) => item.kind === "assistant_message" && item.text), + ); + }); + + it("repoints a block at the canonical source that replaced the streamed one", () => { + const harness = streamHarness(); + harness.send(assistant("Streamed answer")); + const streamed = rows(harness.render()); + expect(streamed[0]).not.toHaveProperty("timelineCursor"); + const source = harness.source(); + const live = source.head[0]; + if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message"); + const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } }; + const present = createStreamPresentation(); + present({ ...presentationOptions, tail: [], head: [live], transform: undefined }); + const repointed = rows( + present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }), + ); + expect(repointed).toMatchObject([ + { + id: streamed[0]!.id, + text: "Streamed answer", + timelineCursor: { epoch: "epoch-1", seq: 42 }, + }, + ]); + }); + + it("keeps a single-paragraph message a one-block group", () => { + const source = hydrateStreamState([ + { event: assistant("Only one paragraph."), timestamp: new Date(1000) }, + ]); + const messageId = source[0]!.id; const result = createStreamPresentation()({ ...presentationOptions, tail: source, head: [], transform: undefined, }); - expect(result.tail).toEqual(source); - expect(result.tail[0]).toBe(source[0]); + expect(result.tail).toMatchObject([ + { + id: `${messageId}:block:0`, + blockGroupId: messageId, + blockIndex: 0, + text: "Only one paragraph.", + }, + ]); + expect(result.tail[0]!.id).not.toBe(messageId); }); it("continues to stream inline reasoning with a stable plugin row", () => { @@ -441,7 +533,13 @@ describe("timeline presentation", () => { text: "ExampleExplain this XML.", }, ]; - expect(projectTimelineItems(items)).toEqual(items); + expect(projectTimelineItems(items)).toEqual( + items.map((item) => + item.kind === "assistant_message" + ? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 } + : item, + ), + ); expect(projectTimelineItems(items)[0]).toBe(items[0]); }); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 19808bdd43..0e83a0f50c 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -23,6 +23,31 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] { : next; } +/** + * The source message a display row belongs to. Every assistant message is split into + * Markdown blocks, so an assistant row id never equals its message id; anything that + * addresses a message — find, scroll-to-message, history reveal — asks for this. + */ +export function getStreamItemMessageId(item: StreamItem): string { + return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id; +} + +/** + * A block is reusable only when it still stands for the same source state. Text and + * turn are what the reader sees; cursor and timestamp are what the timeline reads back + * through the row — a reused block carrying a pre-canonical cursor reports a stale + * reading position and weakens fork-boundary resolution. + */ +function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) { + return ( + block.id === id && + block.turnId === source.turnId && + block.timestamp.getTime() === source.timestamp.getTime() && + block.timelineCursor?.epoch === source.timelineCursor?.epoch && + block.timelineCursor?.seq === source.timelineCursor?.seq + ); +} + /** Source messages reach plugins before any Markdown splitting or Overview grouping. */ export function createStreamPresentation() { const userMessageCache = new WeakMap(); @@ -54,6 +79,13 @@ export function createStreamPresentation() { let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; + /** + * One display row per Markdown block. Each block renders on its own, so a construct + * that needs context from another block does not resolve: `splitMarkdownBlocks` keeps + * link reference definitions with the block that uses them, but a reference pointing + * at a definition several blocks away renders as literal text. Streamed messages + * always behaved this way; history now matches them. + */ function nativeBlocks(item: StreamItem): StreamItem[] { if (item.kind === "user_message") return [presentUserMessage(item)]; if (item.kind !== "assistant_message") return [item]; @@ -71,11 +103,9 @@ export function createStreamPresentation() { growingText = previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length); } - const textBlocks = splitMarkdownBlocks(growingText); - if (prefix.length + textBlocks.length < 2) { - blocksBySource.set(item, [item]); - return [item]; - } + const parsed = splitMarkdownBlocks(growingText); + // Whitespace-only text has no block, and a message still owns exactly one row. + const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""]; const blocks = [...prefix]; for (const [offset, text] of textBlocks.entries()) { @@ -87,9 +117,7 @@ export function createStreamPresentation() { } const existing = previous?.[index]; const id = `${item.id}:block:${index}`; - // Completed display blocks keep their first cursor and object identity while - // the source message grows. The source itself always retains the latest cursor. - if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) { + if (existing?.text === blockText && isReusableBlock(existing, item, id)) { blocks.push(existing); continue; } @@ -108,13 +136,11 @@ export function createStreamPresentation() { return (input: PresentationInput) => { // Retained history is not reprojected or regrouped on each live text update. if (historySource !== input.tail || historyTransform !== input.transform) { + // One rendering path: history splits the same way the live head does, and + // `blocksBySource` keeps both the live block identities and the split cost + // from being paid again when only the tail's array identity changed. historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( - (item) => { - // Preserve live block identities at completion; fetched native Markdown - // stays whole so links and other cross-block constructs keep their context. - if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item]; - return [item.kind === "user_message" ? presentUserMessage(item) : item]; - }, + nativeBlocks, ); historySource = input.tail; historyTransform = input.transform; diff --git a/packages/app/src/agent-stream/prompt-jump-settle.ts b/packages/app/src/agent-stream/prompt-jump-settle.ts index 1ef5e1f7ee..ec15afe177 100644 --- a/packages/app/src/agent-stream/prompt-jump-settle.ts +++ b/packages/app/src/agent-stream/prompt-jump-settle.ts @@ -5,7 +5,7 @@ interface PromptJumpScrollMetrics { } export interface PromptJumpSettleViewport { - findTargetTop(itemId: string): number | null; + findTargetTop(messageId: string): number | null; getContainerTop(): number; getScrollMetrics(): PromptJumpScrollMetrics; setScrollTop(scrollTop: number): void; @@ -13,7 +13,7 @@ export interface PromptJumpSettleViewport { } export interface PromptJumpSettleController { - start(itemId: string): void; + start(messageId: string): void; cancel(): void; isActive(): boolean; } @@ -32,7 +32,7 @@ export function createPromptJumpSettleController(input: { requestFrame: (callback: () => void) => number; cancelFrame: (frameId: number) => void; }): PromptJumpSettleController { - let activeItemId: string | null = null; + let activeMessageId: string | null = null; let frameId: number | null = null; let sampledFrames = 0; let stableFrames = 0; @@ -41,7 +41,7 @@ export function createPromptJumpSettleController(input: { function finish(): void { if (frameId !== null) input.cancelFrame(frameId); unsubscribeFromUserIntent?.(); - activeItemId = null; + activeMessageId = null; frameId = null; sampledFrames = 0; stableFrames = 0; @@ -54,11 +54,11 @@ export function createPromptJumpSettleController(input: { function tick(): void { frameId = null; - const itemId = activeItemId; - if (itemId === null) return; + const messageId = activeMessageId; + if (messageId === null) return; sampledFrames += 1; - const targetTop = input.viewport.findTargetTop(itemId); + const targetTop = input.viewport.findTargetTop(messageId); if (targetTop !== null) { const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop()); if (Math.abs(delta) <= POSITION_TOLERANCE_PX) { @@ -72,7 +72,7 @@ export function createPromptJumpSettleController(input: { const before = input.viewport.getScrollMetrics(); input.viewport.setScrollTop(before.scrollTop + delta); const after = input.viewport.getScrollMetrics(); - const adjustedTargetTop = input.viewport.findTargetTop(itemId); + const adjustedTargetTop = input.viewport.findTargetTop(messageId); let remainingDelta = 0; if (adjustedTargetTop !== null) { remainingDelta = distanceFromLandingPosition( @@ -97,15 +97,15 @@ export function createPromptJumpSettleController(input: { } return { - start(itemId) { + start(messageId) { finish(); - activeItemId = itemId; + activeMessageId = messageId; unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish); scheduleNextFrame(); }, cancel: finish, isActive() { - return activeItemId !== null; + return activeMessageId !== null; }, }; } diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 25cda1829d..4b0d73d681 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -7,7 +7,12 @@ import React, { useRef, useState, } from "react"; -import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual"; +import { + defaultRangeExtractor, + measureElement as measureVirtualElement, + useVirtualizer, + type Range as VirtualRange, +} from "@tanstack/react-virtual"; import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; @@ -36,6 +41,8 @@ import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, } from "./history-start-settle-scheduler"; +import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find"; +import { getStreamItemMessageId } from "./presentation"; import { useScrollToMessage } from "./use-scroll-to-message.web"; interface CreateWebStreamStrategyInput { @@ -369,6 +376,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId; const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady; + // Chat find counts and highlights occurrences from the DOM, so every block row of + // the message it selected has to be mounted even when the scroll window is nowhere + // near it. Without this a hit in a far paragraph is invisible to the count and Next + // walks off to the following message. + const chatFindMessageId = useChatFindSelectedMessageId(); + const chatFindRowIndexes = useMemo(() => { + if (!chatFindMessageId) return null; + const indexes = segments.historyVirtualized.flatMap((item, index) => + getStreamItemMessageId(item) === chatFindMessageId ? [index] : [], + ); + return indexes.length > 0 ? indexes : null; + }, [chatFindMessageId, segments.historyVirtualized]); + const rangeExtractor = useCallback( + (range: VirtualRange) => { + const visible = defaultRangeExtractor(range); + if (!chatFindRowIndexes) return visible; + return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right); + }, + [chatFindRowIndexes], + ); const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, @@ -379,6 +406,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return row ? estimateStreamItemHeight(row) : 120; }, measureElement: measureVirtualElement, + rangeExtractor, scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX, useAnimationFrameWithResizeObserver: true, overscan: 8, @@ -1192,7 +1220,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( -
+
{renderHistoryMountedRow(item, index, segments.historyMounted)}
)); @@ -1200,7 +1233,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const liveHeadRows = useMemo(() => { void liveHeadRowRevision; return segments.liveHead.map((item, index) => ( -
+
{renderLiveHeadRow(item, index, segments.liveHead)}
)); @@ -1253,6 +1291,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool key={virtualRow.key} data-index={virtualRow.index} data-history-row-id={item.id} + data-message-id={getStreamItemMessageId(item)} ref={measureVirtualizedRowElement} style={renderVirtualRowStyle(virtualRow.start)} > diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f0519c873..ce56547a5e 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -40,13 +40,20 @@ export interface StreamEdgeSlotProps { ListFooterComponentStyle?: StyleProp; } +/** + * A caller that landed on one occurrence inside the message owns where the viewport + * settles. It resolved the occurrence against a specific row already, so it takes no + * row here and returns null once that row is gone. + */ +export interface ScrollToMessageOccurrence { + signal: AbortSignal; + targetTop(): number | null; +} + export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; - scrollToMessage?: ( - itemId: string, - occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null }, - ) => void; + scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => 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 b207417ef8..6de7a3296c 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,13 +1,16 @@ import type React from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { Virtualizer } from "@tanstack/react-virtual"; +import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId } from "./presentation"; import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; +import type { ScrollToMessageOccurrence } from "./strategy"; interface UseScrollToMessageInput { active: boolean; scrollContainerRef: React.RefObject; rowVirtualizer: Virtualizer; - historyVirtualized: readonly { id: string }[]; + historyVirtualized: readonly StreamItem[]; cancelPendingStickToBottom: () => void; setFollowOutput: (value: boolean) => boolean; onNearBottomChange: (value: boolean) => void; @@ -32,24 +35,22 @@ export function useScrollToMessage({ setFollowOutput, onNearBottomChange, }: UseScrollToMessageInput) { - const occurrenceRef = useRef< - { signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined - >(undefined); + const occurrenceRef = useRef(undefined); const removeAbortListener = useRef<(() => void) | null>(null); const settleController = useMemo( () => createPromptJumpSettleController({ viewport: { - findTargetTop(itemId) { - const container = scrollContainerRef.current; - const target = container?.querySelector( - `[data-history-row-id="${CSS.escape(itemId)}"]`, - ); + findTargetTop(messageId) { const occurrence = occurrenceRef.current; if (occurrence?.signal.aborted) return null; - if (!target) return null; - if (occurrence) return occurrence.targetTop(target); - return target.getBoundingClientRect().top; + if (occurrence) return occurrence.targetTop(); + // Every row of an assistant message carries the message id; the first one + // is the message's top. + const target = scrollContainerRef.current?.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + return target ? target.getBoundingClientRect().top : null; }, getContainerTop() { return scrollContainerRef.current?.getBoundingClientRect().top ?? 0; @@ -102,10 +103,7 @@ export function useScrollToMessage({ }, [active, settleController]); const scrollToMessage = useCallback( - ( - itemId: string, - occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null }, - ) => { + (messageId: string, occurrence?: ScrollToMessageOccurrence) => { occurrenceRef.current = occurrence; removeAbortListener.current?.(); const cancel = () => settleController.cancel(); @@ -118,7 +116,7 @@ export function useScrollToMessage({ setFollowOutput(false); const mounted = container.querySelector( - `[data-history-row-id="${CSS.escape(itemId)}"]`, + `[data-message-id="${CSS.escape(messageId)}"]`, ); if (mounted) { const delta = @@ -126,15 +124,17 @@ export function useScrollToMessage({ container.getBoundingClientRect().top - PROMPT_JUMP_TOP_INSET_PX; container.scrollTop += delta; - settleController.start(itemId); + settleController.start(messageId); onNearBottomChange(false); return; } - const index = historyVirtualized.findIndex((item) => item.id === itemId); + const index = historyVirtualized.findIndex( + (item) => getStreamItemMessageId(item) === messageId, + ); if (index >= 0) { rowVirtualizer.scrollToIndex(index, { align: "start" }); - settleController.start(itemId); + settleController.start(messageId); onNearBottomChange(false); } }, diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 03303d712d..42168f052d 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -1,13 +1,20 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { StreamItem } from "@/types/stream"; import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window"; +import { getStreamItemMessageId } from "./presentation"; + +// Callers reveal a message, not a row: an assistant message is several block rows and +// the window has to open at the first of them. +function findMessageRowIndex(items: StreamItem[], messageId: string): number { + return items.findIndex((item) => getStreamItemMessageId(item) === messageId); +} function findEarlierHistoryWindowStart(input: { items: StreamItem[]; start: number; itemId: string | undefined; }): number { - const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1; + const targetIndex = input.itemId ? findMessageRowIndex(input.items, input.itemId) : -1; const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start; if (!input.itemId) { return findMountedWindowStart({ @@ -90,7 +97,7 @@ export function useStreamHistoryWindow(input: { const revealLoadedHistory = useCallback( (itemId?: string): boolean => { - const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1; + const targetIndex = itemId ? findMessageRowIndex(items, itemId) : -1; if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) { return false; } diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 11cfa52827..3fe17a7bee 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -62,7 +62,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { createStreamPresentation } from "./presentation"; +import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; @@ -605,10 +605,14 @@ const AgentStreamViewComponent = forwardRef { toast?.error(t("agentStream.historyLoadFailed")); }, [t, toast]); - const visibleHistoryItemIds = useMemo( + // Chat find and the chat outline address messages, and an assistant message is a + // group of block rows, so this is a set of message ids and never of row ids. + const visibleMessageIds = useMemo( () => new Set( - [...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id), + [...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map( + getStreamItemMessageId, + ), ), [baseRenderModel.history, baseRenderModel.segments.liveHead], ); @@ -621,8 +625,8 @@ const AgentStreamViewComponent = forwardRef - + {(renderFullContent) => ( diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index c53cba9f98..04aa28bd99 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -87,6 +87,31 @@ function selectNodeContents(element: Element): Selection { return selection; } +interface TranscriptMessage { + messageId: string; + blocks: string[]; +} + +/** + * The web transcript's shape: a scroll container holding one row per Markdown block, + * each row carrying its message id and wrapping its own assistant message element. + */ +function mountTranscript(messages: TranscriptMessage[]): HTMLElement { + const transcript = document.createElement("div"); + transcript.setAttribute("data-testid", "agent-chat-scroll"); + for (const { messageId, blocks } of messages) { + for (const [index, text] of blocks.entries()) { + const row = document.createElement("div"); + row.setAttribute("data-history-row-id", `${messageId}:block:${index}`); + row.setAttribute("data-message-id", messageId); + row.innerHTML = `
${text}
`; + transcript.append(row); + } + } + document.body.append(transcript); + return transcript; +} + function copiedMarkdown(selection: Selection): string | null { return createAssistantSelectionClipboardContent(selection)?.plainText ?? null; } @@ -110,6 +135,36 @@ describe("assistant selection copy ranges", () => { expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull(); }); + it("copies a selection that runs across the block rows of one message", () => { + const transcript = mountTranscript([ + { messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] }, + ]); + const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]'); + expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond"); + }); + + it("does not replace the browser clipboard for a selection reaching a second message", () => { + const transcript = mountTranscript([ + { messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] }, + { messageId: "message-2", blocks: ["Reply paragraph."] }, + ]); + const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]'); + expect(copiedMarkdown(selectRange(blocks[1]!, 0, blocks[2]!, 5))).toBeNull(); + }); + + // A retained inactive panel renders the same agent, so the same message id can exist + // twice in the document. A group is only ever read out of one transcript, and rows + // that belong to no transcript are not a group at all. + it("does not assemble a message group from rows outside a transcript", () => { + const transcript = mountTranscript([ + { messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] }, + ]); + document.body.append(...transcript.children); + transcript.remove(); + const blocks = document.querySelectorAll('[data-paseo-markdown-tag="p"]'); + expect(copiedMarkdown(selectRange(blocks[0]!, 0, blocks[1]!, 17))).toBeNull(); + }); + it("does not replace the browser clipboard for a range spanning assistant messages", () => { const firstMessage = mountFixture(); const secondMessage = mountFixture(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index f04ddfb3e2..d019a23012 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -17,6 +17,9 @@ import { } from "./markup"; const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]'; +const MESSAGE_ROW_SELECTOR = "[data-message-id]"; +const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]'; +const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`; const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`; const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`; @@ -64,20 +67,22 @@ export function createAssistantSelectionClipboardContent( } const range = selection.getRangeAt(0); - const startMessage = closestAssistantMessage(range.startContainer); - const endMessage = closestAssistantMessage(range.endContainer); - if (!startMessage || startMessage !== endMessage) { + const parts = selectedMessageParts(range); + if (!parts) { return null; } - const partialCode = createPartialCodeContent(range, startMessage); - if (partialCode) { - return partialCode; + if (parts.length === 1) { + const partialCode = createPartialCodeContent(range, parts[0]!.message); + if (partialCode) { + return partialCode; + } } const container = document.createElement("div"); - const selected = cloneMarkdownSelection(range, startMessage); - container.append(selected); + for (const part of parts) { + container.append(cloneMarkdownSelection(part.range, part.message)); + } restoreMarkdownElements(container); const markdown = turndown.turndown(container.innerHTML).trim(); @@ -407,6 +412,70 @@ function closestAssistantMessage(node: Node): Element | null { return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null; } +interface SelectedMessagePart { + message: Element; + range: Range; +} + +function messageIdOf(node: Element): string | undefined { + return node.closest(MESSAGE_ROW_SELECTOR)?.dataset.messageId; +} + +/** + * A retained inactive panel keeps rendering its chat, so the same agent — and the same + * message id — can exist twice in the document. The selection's own transcript is the + * only one that can answer for it. + */ +function assistantMessagesOfGroup(node: Element, messageId: string): Element[] { + const transcript = node.closest(CHAT_SCROLL_SELECTOR); + if (!transcript) { + return []; + } + const rows = transcript.querySelectorAll(messageRowSelector(messageId)); + return Array.from(rows).flatMap((row) => { + const message = row.querySelector(ASSISTANT_MESSAGE_SELECTOR); + return message ? [message] : []; + }); +} + +/** + * The selection cut into one range per assistant message element it covers. + * + * An assistant message renders one row per Markdown block, so a selection that runs + * past a paragraph ends in a different element of the same message. Each block is + * serialized against its own element, exactly as a selection inside one block is, and + * the results are concatenated — Turndown never sees the row wrappers between them. + * A selection reaching a second message, or anything that is not assistant Markdown, + * copies as plain text. + */ +function selectedMessageParts(range: Range): SelectedMessagePart[] | null { + const start = closestAssistantMessage(range.startContainer); + const end = closestAssistantMessage(range.endContainer); + if (!start || !end) { + return null; + } + if (start === end) { + return [{ message: start, range }]; + } + const messageId = messageIdOf(start); + if (!messageId || messageId !== messageIdOf(end)) { + return null; + } + const messages = assistantMessagesOfGroup(start, messageId); + const first = messages.indexOf(start); + const last = messages.indexOf(end); + if (first < 0 || last < first) { + return null; + } + return messages.slice(first, last + 1).map((message) => { + const part = message.ownerDocument.createRange(); + part.selectNodeContents(message); + if (message === start) part.setStart(range.startContainer, range.startOffset); + if (message === end) part.setEnd(range.endContainer, range.endOffset); + return { message, range: part }; + }); +} + function restoreMarkdownElements(container: HTMLElement): void { for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { ignored.remove(); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0bbe1c78e3..c7e6199af5 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { paneFind: { - searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", + connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", + historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.", + revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.", searching: "جارٍ البحث…", loading: "جارٍ التحميل…", failed: "فشل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7cd1984a5d..9ae394bc29 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,6 +1,8 @@ export const en = { paneFind: { - searchFailed: "Could not search this chat. Check the host connection and retry.", + connectionFailure: "Could not search this chat. Check the host connection and retry.", + historyChangedFailure: "The chat changed while searching. Search again.", + revealFailure: "Could not show this match. Retry.", searching: "Searching…", loading: "Loading…", failed: "Failed", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ba66d68224..7c3a9a15ad 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { paneFind: { - searchFailed: + connectionFailure: "No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.", + historyChangedFailure: "El chat cambió durante la búsqueda. Vuelve a buscar.", + revealFailure: "No se pudo mostrar esta coincidencia. Vuelve a intentarlo.", searching: "Buscando…", loading: "Cargando…", failed: "Error", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b4ca2d7468..310230d930 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { paneFind: { - searchFailed: + connectionFailure: "Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.", + historyChangedFailure: "La conversation a changé pendant la recherche. Relancez la recherche.", + revealFailure: "Impossible d’afficher ce résultat. Réessayez.", searching: "Recherche…", loading: "Chargement…", failed: "Échec", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4f1236698e..605ca7454d 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { paneFind: { - searchFailed: + connectionFailure: "このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。", + historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。", + revealFailure: "この一致を表示できませんでした。再試行してください。", searching: "検索中…", loading: "読み込み中…", failed: "失敗", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 76d35a3ac4..6e0a7897ba 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { paneFind: { - searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", + connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", + historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.", + revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.", searching: "검색 중…", loading: "불러오는 중…", failed: "실패", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 88373ae583..9c9ec29bf2 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { paneFind: { - searchFailed: + connectionFailure: "Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.", + historyChangedFailure: "A conversa mudou durante a busca. Busque novamente.", + revealFailure: "Não foi possível mostrar esta correspondência. Tente novamente.", searching: "Buscando…", loading: "Carregando…", failed: "Falhou", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c77395d892..6a1b9e4a9b 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { paneFind: { - searchFailed: + connectionFailure: "Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.", + historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.", + revealFailure: "Не удалось показать это совпадение. Повторите попытку.", searching: "Поиск…", loading: "Загрузка…", failed: "Ошибка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c47f65b153..467bf3cdeb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { paneFind: { - searchFailed: "无法搜索此聊天。请检查主机连接后重试。", + connectionFailure: "无法搜索此聊天。请检查主机连接后重试。", + historyChangedFailure: "搜索期间聊天已更改。请重新搜索。", + revealFailure: "无法显示此匹配项。请重试。", searching: "搜索中…", loading: "加载中…", failed: "失败", diff --git a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts index 3de5188e3b..22eddf8152 100644 --- a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts +++ b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts @@ -75,6 +75,49 @@ describe("splitMarkdownBlocks", () => { ).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]); }); + it("keeps a link reference definition with the paragraph that uses it", () => { + expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([ + "See the [docs][d].\n\n[d]: https://example.com", + ]); + }); + + it("folds a leading definition-only block into the block below it", () => { + expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([ + '[d]: https://example.com "Docs"\n\nSee the [docs][d].', + ]); + }); + + it("folds several definition lines and several definition blocks into one block", () => { + expect( + splitMarkdownBlocks( + "See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: \n\n[c]: https://example.com/c 'Third'\n\nAfter", + ), + ).toEqual([ + "See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: \n\n[c]: https://example.com/c 'Third'", + "After", + ]); + }); + + it("recognizes every destination the renderer accepts, including escaped spaces", () => { + expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([ + "See [docs].\n\n[docs]: docs\\ folder/readme", + ]); + expect(splitMarkdownBlocks("See [docs].\n\n[docs]: 'Title'")).toEqual([ + "See [docs].\n\n[docs]: 'Title'", + ]); + }); + + it("leaves a definition-only message as its own block", () => { + expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]); + }); + + it("does not fold a paragraph that merely starts with a bracketed link", () => { + expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([ + "Intro", + "[Link](https://example.com) and more prose", + ]); + }); + it("treats triple newlines as a split point and filters empty blocks", () => { expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([ "First paragraph", diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts index 7426b88cb9..8282fc3ca9 100644 --- a/packages/app/src/utils/split-markdown-blocks.ts +++ b/packages/app/src/utils/split-markdown-blocks.ts @@ -4,6 +4,35 @@ import MarkdownIt from "markdown-it"; const markdownBlockParser = new MarkdownIt(); markdownBlockParser.core.ruler.disable("inline"); +// The renderer decides what counts as a definition, so ask the same parser: a block +// that produces no tokens but registers references is nothing but definitions. +function isLinkReferenceDefinitionBlock(block: string): boolean { + const env: { references?: Record } = {}; + const tokens = markdownBlockParser.parse(block, env); + return tokens.length === 0 && Object.keys(env.references ?? {}).length > 0; +} + +/** + * Definitions render nothing and resolve nothing on their own, so a block made only of + * them would paint an empty row and break every reference that pointed at it. Fold it + * into the block it belongs to: the one above, or the one below when it leads. + */ +function foldLinkReferenceDefinitions(blocks: string[]): string[] { + const folded: string[] = []; + let leading: string[] = []; + for (const block of blocks) { + if (isLinkReferenceDefinitionBlock(block)) { + if (folded.length > 0) folded[folded.length - 1] += `\n\n${block}`; + else leading.push(block); + continue; + } + folded.push([...leading, block].join("\n\n")); + leading = []; + } + if (leading.length > 0) folded.push(leading.join("\n\n")); + return folded; +} + export function splitMarkdownBlocks(text: string): string[] { if (text.length === 0) { return []; @@ -43,7 +72,7 @@ export function splitMarkdownBlocks(text: string): string[] { blocks.push(currentLines.join("\n")); } - return blocks.filter((block) => block.length > 0); + return foldLinkReferenceDefinitions(blocks.filter((block) => block.length > 0)); } function getStructuralBlankLines(text: string, lines: string[]): Set {