Find text in assistant messages that streamed in the open chat (#5146)

* fix(app): find text in assistant messages that streamed in the open chat

Chat find failed with the generic "check the host connection" text
whenever the oldest hit sat in a response that streamed while the chat
was open and had more than one Markdown block. Presentation rendered
such a message as one row per block with ids `<messageId>:block:n`,
while hydrated history stayed one row with the raw message id, and every
message-addressed DOM lookup (find reveal, scroll-to-message, history
reveal, the render-cap expansion) only knew the raw id. The reveal step
polled for a row that never existed and gave up after five seconds.

The defect was the dual rendering, so this removes it: every assistant
message is a block group however it arrived, single-paragraph messages
included, so a row id is never a message id. Rows carry
`data-message-id`, `getStreamItemMessageId` is the one way from a row to
its message, and find, scroll-to-message, history reveal and selection
copy address messages through it. Chat find force-mounts every row of
the selected message under virtualization so counts span the whole
message, and pins the chosen occurrence to its row while the scroll
settles. Link reference definitions fold into their neighbouring block
so history never paints an empty row. Find failures now say what went
wrong (connection, history changed, could not show the match) instead
of blaming the connection for everything.

* fix(app): let the Markdown parser decide which blocks are link definitions

A hand-rolled CommonMark regex rejected destinations markdown-it accepts,
such as escaped spaces, so those definitions still became their own empty
row and the neighbouring paragraph lost its link. The block splitter now
parses the block with the same parser: no tokens plus registered
references means definitions only. The two new chat find journeys move
their mechanics into named helpers so the test bodies read as intent.
This commit is contained in:
Mohamed Boudra
2026-09-21 22:31:08 +02:00
committed by GitHub
parent 2c8e8a8268
commit 135a3b4c9e
35 changed files with 1007 additions and 201 deletions
+17 -3
View File
@@ -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.
@@ -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<void> {
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<void> {
await selectAssistantTextRange(page, "Direct matches:", "ready");
}
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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"],
+136 -5
View File
@@ -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 &amp; 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<void>;
}
/**
* 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<StreamedReplyChat> {
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<MockAgentWorkspace> {
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);
@@ -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;
}
@@ -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<string | null>(null);
const SelectedMessage = createContext<string | null>(null);
const FAILURE_TEXT: Record<ChatFindFailure, string> = {
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<PaneFindHandle>(null);
const root = useRef<HTMLDivElement>(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 (
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
<SelectedMessage.Provider value={state.selectedItemId}>{children}</SelectedMessage.Provider>
{state.open && (
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
<PaneFind
@@ -130,9 +144,9 @@ export function ChatFind({
onPrevious={model.previous}
onClose={close}
/>
{state.error && (
{state.failure && (
<View style={styles.error}>
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
<Button size="xs" variant="ghost" onPress={model.retry}>
{t("paneFind.retry")}
</Button>
@@ -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();
}
@@ -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<T>(failure: ChatFindFailure, operation: Promise<T>): Promise<T> {
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<AgentTimelineSearchPayload>;
load(epoch: string, seq: number): Promise<unknown>;
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) {
@@ -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 = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${block}</div></div>`;
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 =
'<div data-message-text="true"><div data-paseo-markdown-tag="p">alpha three</div></div>';
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(
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
@@ -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<HTMLElement>(`[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 [];
@@ -8,11 +8,11 @@ export interface ChatFindProps {
epoch: string | null;
items: StreamItem[];
viewportRef: RefObject<StreamViewportHandle | null>;
revealLoadedItem(itemId: string): boolean;
visibleItemIds: ReadonlySet<string>;
revealLoadedMessage(messageId: string): boolean;
visibleMessageIds: ReadonlySet<string>;
children: ReactNode;
}
export interface ChatFindExpansionProps {
itemId: string;
messageId: string;
children(renderFullContent: boolean): ReactNode;
}
@@ -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 = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${text}</div></div>`;
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<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds"> =
{
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<HTMLElement>(
"[data-history-row-id]",
);
return row?.dataset.historyRowId;
}
@@ -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<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds">;
getRoot(): HTMLElement | null;
highlightName: string;
}
function rowOf(range: Range): HTMLElement | null {
return range.startContainer.parentElement?.closest<HTMLElement>("[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<HTMLElement>(
`[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<HTMLElement>(
@@ -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<string>() } },
{ initialProps: { visibleMessageIds: new Set<string>() } },
);
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<void>();
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<string>(),
visibleMessageIds: new Set<string>(),
},
},
);
@@ -327,11 +327,11 @@ describe("useChatOutline", () => {
act(() => result.current.jumpToPrompt(1));
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
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,
}),
);
@@ -33,8 +33,8 @@ export interface UseChatOutlineInput {
enabled: boolean;
viewportRef: RefObject<StreamViewportHandle | null>;
onJumpError: () => void;
visibleItemIds?: ReadonlySet<string>;
revealLoadedItem?: (itemId: string) => boolean;
visibleMessageIds?: ReadonlySet<string>;
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<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(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 };
@@ -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: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
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]);
});
+40 -14
View File
@@ -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<UserMessageItem, UserMessageItem>();
@@ -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<StreamItem>(
(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;
@@ -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;
},
};
}
+42 -3
View File
@@ -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) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</div>
));
@@ -1200,7 +1233,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveHeadRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
<div
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderLiveHeadRow(item, index, segments.liveHead)}
</div>
));
@@ -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)}
>
+11 -4
View File
@@ -40,13 +40,20 @@ export interface StreamEdgeSlotProps {
ListFooterComponentStyle?: StyleProp<ViewStyle>;
}
/**
* 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 {
@@ -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<HTMLElement | null>;
rowVirtualizer: Virtualizer<HTMLElement, Element>;
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<ScrollToMessageOccurrence | undefined>(undefined);
const removeAbortListener = useRef<(() => void) | null>(null);
const settleController = useMemo(
() =>
createPromptJumpSettleController({
viewport: {
findTargetTop(itemId) {
const container = scrollContainerRef.current;
const target = container?.querySelector<HTMLElement>(
`[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<HTMLElement>(
`[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<HTMLElement>(
`[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);
}
},
@@ -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;
}
+12 -8
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
const handleTimelineHistoryLoadError = useCallback(() => {
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<AgentStreamViewHandle, AgentStreamVi
enabled: supportsChatOutline && chatOutlineEnabled,
viewportRef,
onJumpError: handleTimelineHistoryLoadError,
visibleItemIds: visibleHistoryItemIds,
revealLoadedItem: revealLoadedHistory,
visibleMessageIds,
revealLoadedMessage: revealLoadedHistory,
});
useImperativeHandle(
@@ -718,7 +722,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
>
<ChatFindExpansion itemId={item.id}>
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
{(renderFullContent) => (
<AssistantMessage
renderFullContent={renderFullContent}
@@ -1098,8 +1102,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
epoch={timelineEpoch}
items={findItems}
viewportRef={viewportRef}
revealLoadedItem={revealLoadedHistory}
visibleItemIds={visibleHistoryItemIds}
revealLoadedMessage={revealLoadedHistory}
visibleMessageIds={visibleMessageIds}
>
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
@@ -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 = `<div data-testid="assistant-message"><div data-paseo-markdown-tag="p">${text}</div></div>`;
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();
@@ -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<HTMLElement>(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<HTMLElement>(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();
+3 -1
View File
@@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.",
searching: "جارٍ البحث…",
loading: "جارٍ التحميل…",
failed: "فشل",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。",
revealFailure: "この一致を表示できませんでした。再試行してください。",
searching: "検索中…",
loading: "読み込み中…",
failed: "失敗",
+3 -1
View File
@@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.",
searching: "검색 중…",
loading: "불러오는 중…",
failed: "실패",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -3,8 +3,10 @@ import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
searchFailed:
connectionFailure:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.",
revealFailure: "Не удалось показать это совпадение. Повторите попытку.",
searching: "Поиск…",
loading: "Загрузка…",
failed: "Ошибка",
+3 -1
View File
@@ -3,7 +3,9 @@ import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
searchFailed: "无法搜索此聊天。请检查主机连接后重试。",
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
revealFailure: "无法显示此匹配项。请重试。",
searching: "搜索中…",
loading: "加载中…",
failed: "失败",
@@ -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]: <https://example.com/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]: <https://example.com/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]: <docs folder/readme> 'Title'")).toEqual([
"See [docs].\n\n[docs]: <docs folder/readme> '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",
@@ -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<string, unknown> } = {};
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<number> {