mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
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 <antonino.grasso@device-insight.com> * 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 <antonino.grasso@device-insight.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
a-grasso
parent
142cdac8d2
commit
e2ad26d238
@@ -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<HTMLElement>(
|
||||
'[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,
|
||||
}) => {
|
||||
|
||||
@@ -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(
|
||||
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
|
||||
);
|
||||
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 = "<div>needle</div>";
|
||||
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(
|
||||
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
|
||||
row.querySelector<HTMLElement>("[popover]")!.showPopover();
|
||||
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps text around nested plugin blocks in separate occurrences", () => {
|
||||
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>('[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<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[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) {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1267,6 +1268,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}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1329,6 +1331,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user