Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andJacob Dietle 3b5aabe22f test(app): reproduce permission alert loss after a slow reconnect
Preserve the failing reload journey for #7078. The cached directory acknowledges an agent sequence after discarding its pending permission; a changes read leaves the inactive tab showing a running ring. Delay only the reconnect handshake to make the cache-first ordering deterministic. No production fix is included.

Co-authored-by: Jacob Dietle <128077360+jacob-dietle@users.noreply.github.com>
2026-10-17 23:16:05 +02:00
paseo-bot[bot]andhelloimdevman 9bd58f66b2 Copy Korean terminal text without extra spaces (#7254)
* fix(terminal): preserve Korean text when copying native terminal cells

* fix(terminal): omit continuation cells from copied text

Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation.

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>

---------

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 22:57:04 +02:00
paseo-bot[bot]anda-grasso e2ad26d238 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>
2026-10-17 21:58:07 +02:00
paseo-ai[bot] 142cdac8d2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-17 18:18:08 +00:00
9 changed files with 272 additions and 16 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+33 -1
View File
@@ -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,
}) => {
@@ -0,0 +1,93 @@
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { readReplicaCache } from "../support/helpers/replica-cache-storage";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
async function selectConversation(page: Page, title: string): Promise<void> {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function waitForDirectoryCheckpoint(page: Page): Promise<void> {
await expect
.poll(async () => {
const cache = await readReplicaCache(page);
return cache?.hosts?.some((host) => host.directorySync !== undefined);
})
.toBe(true);
}
async function expectWaitingTab(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab).toBeVisible();
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0);
// The alert SVG has no accessible name; the provider icon is the other SVG.
await expect(tab.locator("svg")).toHaveCount(2);
}
function observeAgentSyncMode(page: Page): () => unknown {
let mode: unknown;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
const envelope = JSON.parse(String(payload));
if (envelope.type === "session" && envelope.message.type === "fetch_agents_response") {
mode = envelope.message.payload.sync.mode;
}
});
});
return () => mode;
}
async function reloadWithSlowReconnect(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), async (socket) => {
// Fault injection: restore the disk baseline before the handshake completes.
await new Promise((resolve) => setTimeout(resolve, 2_000));
socket.connectToServer();
});
await page.reload();
}
test("keeps an inactive agent's permission alert after reload", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const syncMode = observeAgentSyncMode(page);
const waiting = await seedMockAgentWorkspace({
repoPrefix: "permission-reload-",
title: "Needs approval",
model: "thirty-minute-stream",
initialPrompt: "Emit synthetic plan approval.",
});
try {
await openAgentRoute(page, waiting);
await waitForPermissionPrompt(page);
await waiting.client.createAgent({
provider: "mock",
cwd: waiting.cwd,
workspaceId: waiting.workspaceId,
title: "Other conversation",
modeId: "load-test",
model: "thirty-minute-stream",
initialPrompt: "Long task",
});
await selectConversation(page, "Other conversation");
await expectWaitingTab(page, "Needs approval");
await waitForDirectoryCheckpoint(page);
await testInfo.attach("before-reload", {
body: await page.screenshot(),
contentType: "image/png",
});
await reloadWithSlowReconnect(page);
await expect(
page.getByRole("button", { name: "Other conversation", exact: true }),
).toBeVisible();
await expect.poll(syncMode).toBe("changes");
await testInfo.attach("after-reload", {
body: await page.screenshot(),
contentType: "image/png",
});
await expectWaitingTab(page, "Needs approval");
await selectConversation(page, "Needs approval");
await waitForPermissionPrompt(page);
} finally {
await waiting.cleanup();
}
});
@@ -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)}
>
@@ -41,6 +41,51 @@ function rowWithText(
}
describe("native terminal selection", () => {
it("copies Korean output without adding spaces for continuation cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
await copyTerminalSelection({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual(["한글 문장"]);
} finally {
terminal.dispose();
}
});
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장 | 성공");
const bounds = terminal.getBufferBounds();
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe("한글 문장 | 성공");
} finally {
terminal.dispose();
}
});
it("expands a long-press coordinate to the whole terminal word", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
await terminal.write("before PASEO_TARGET after\r\n");
@@ -327,7 +327,10 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
const startCol = startsOnThisRow ? selection.start.col : 0;
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
const line = selectedCells.map((cell) => cell.char || " ").join("");
const line = selectedCells
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join("");
if (offset > 0 && window.wrappedRows[offset] !== true) {
text += "\n";
}
+7
View File
@@ -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: