From e2ad26d23875eb0161c46e7376abfca7054e578b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:58:07 +0200 Subject: [PATCH] Find source-message text in plugin timeline rows (#7253) * fix(app): find text in rows a timeline plugin draws Chat find located a replaced message by its source id but counted matches only inside elements marked as message text, which a plugin renderer never sets, so a replaced prompt or reply reported no matches. Mark plugin rows and search the whole row, with each view bounding a match. * docs(plugins): qualify search in replacement timeline rows Chat find discovers source-message matches before inspecting a replacement renderer. Document that renderer-added text is not independently indexed. Co-authored-by: a-grasso * fix(app): find visible text within contiguous plugin blocks A whole-row walk counted closed popover text and reunited text before and after nested blocks. Keep consecutive block groups and exclude plugin text the browser is not drawing. --------- Co-authored-by: a-grasso Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/chat-find.spec.ts | 34 +++++++++++- .../chat-find/ranges.browser.test.ts | 46 ++++++++++++++++ .../src/agent-stream/chat-find/ranges.web.ts | 53 ++++++++++++++----- .../app/src/agent-stream/strategy-web.tsx | 3 ++ public-docs/plugins/reference.md | 7 +++ 5 files changed, 129 insertions(+), 14 deletions(-) diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index b29a5f5b89..a7316fe165 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -19,6 +19,10 @@ import { } from "../support/helpers/composer"; import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center"; +import { + expectPromptsDrawnByPlugin, + withPromptTimelinePlugin, +} from "../support/helpers/plugin-timeline"; 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"; @@ -174,7 +178,7 @@ async function highlightedMessage(page: Page) { const range = entry && Array.from(entry[1])[0]; if (!(range instanceof Range)) return null; const message = range.startContainer.parentElement?.closest( - '[data-testid="user-message"], [data-testid="assistant-message"]', + '[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]', ); return message?.dataset.testid ?? null; }); @@ -219,6 +223,34 @@ test("counts and steps through every match in the chat, not just the selected me } }); +test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-plugin-prompt-", + title: "Chat Find plugin prompt", + initialPrompt: ACROSS_PROMPT, + featureValues: { mockAssistantResponse: ACROSS_RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await withPromptTimelinePlugin(async () => { + await openAgentRoute(page, agent); + await expectPromptsDrawnByPlugin(page); + await openChatFind(page); + await query(page).fill("scope-needle"); + await expect(status(page)).toHaveText("1 of 3"); + await expectHighlight(page, "scope-needle"); + await expectHighlightedMessage(page, "plugin-prompt"); + await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") }); + await expectNextMatch(page, "2 of 3", "scope-needle"); + await expectHighlightedMessage(page, "assistant-message"); + await closeFind(page); + }); + } finally { + await agent.cleanup(); + } +}); + test("rejects invisible source matches and ignores a late reply after closing", async ({ page, }) => { 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 631b8f1d27..8af6f83f65 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 @@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", ( ]); expect(findRenderedMatches(row, ".another")).toEqual([]); }); + +function pluginRow(html: string) { + const row = document.createElement("div"); + row.dataset.pluginRow = "true"; + row.dataset.messageId = "message-1"; + row.innerHTML = html; + document.body.append(row); + return row; +} + +it("searches a plugin row that sets no text marker, bounding matches by view", () => { + const row = pluginRow( + "
Prompt
find this needle
needle
", + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([ + "needle", + "needle", + ]); + expect(findRenderedMatches(row, "Promptfind")).toEqual([]); + expect(findRenderedMatches(row, "find this needle")).toHaveLength(1); + expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2); +}); + +it("leaves rows without the plugin marker limited to marked message text", () => { + const row = document.createElement("div"); + row.innerHTML = "
needle
"; + document.body.append(row); + expect(findRenderedMatches(row, "needle")).toEqual([]); +}); + +it("counts only drawn text in a plugin row, including an opened popover", () => { + const row = pluginRow( + '
visible needle
display needle
visibility needletransparent needle
popover needle
', + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]); + row.querySelector("[popover]")!.showPopover(); + expect(findRenderedMatches(row, "needle")).toHaveLength(2); +}); + +it("keeps text around nested plugin blocks in separate occurrences", () => { + const row = pluginRow("
alpha
details
beta
gamma
delta
"); + expect(findRenderedMatches(row, "alpha beta")).toEqual([]); + expect(findRenderedMatches(row, "gamma delta")).toEqual([]); + expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]); + expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]); +}); 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 ea2e901c2d..13da1034a2 100644 --- a/packages/app/src/agent-stream/chat-find/ranges.web.ts +++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts @@ -21,28 +21,55 @@ export function findMessageMatches( return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query)); } +function renderedTextGroups(row: HTMLElement): Text[][] { + const groups: Text[][] = []; + // A plugin renderer owns the DOM of the message it replaced and sets no markers, so the + // whole row is searched and each div (a React Native View or Text) bounds a match. + const pluginRow = row.dataset.pluginRow === "true"; + const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS; + const contents = pluginRow + ? [row] + : row.querySelectorAll('[data-message-text="true"]'); + for (const content of contents) { + let group: { block: Element; texts: Text[] } | undefined; + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node instanceof Element) { + // Even an empty nested block separates the text before and after it. + if (node.matches(blocks)) group = undefined; + continue; + } + if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; + if ( + pluginRow && + !node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true }) + ) + continue; + const block = node.parentElement?.closest(blocks) ?? content; + if (group?.block !== block) { + group = { block, texts: [] }; + groups.push(group.texts); + } + group.texts.push(node); + } + } + return groups; +} + /** 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()) { + for (const texts of renderedTextGroups(row)) { const points = texts.map((node) => ({ node, start: 0, end: 0 })); let text = ""; for (const point of points) { diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 97490c2a17..00974867e4 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1250,6 +1250,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool key={item.id} data-history-row-id={item.id} data-message-id={getStreamItemMessageId(item)} + data-plugin-row={item.kind === "plugin" ? "true" : undefined} style={streamRowStyle} > diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index cc8023b251..4a8f74af9a 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us `useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in assistant rows. +Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also +matches the original message. The host searches the original prompt text or the assistant reply +with Markdown syntax removed, then the app counts matches in the text your renderer draws. +Text added only by your renderer is not independently indexed. Matches stay within a rendered +block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on +the web and desktop apps. + ### Append a timeline row from the daemon A server handler can add a plugin-owned row to canonical history: