Merge branch 'main' into fix/plugin-type-walk-node-builtins

This commit is contained in:
Gabriele Magno
2026-10-17 10:49:30 +02:00
committed by GitHub
258 changed files with 10031 additions and 8960 deletions
@@ -23,13 +23,16 @@ jobs:
platform: android
profile: production
submit_ios:
name: Submit iOS
distribute_ios:
name: Distribute iOS to TestFlight
needs: [build_ios]
type: submit
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -41,7 +44,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, submit_ios]
needs: [build_ios, distribute_ios]
environment: production
runs_on: macos-medium
steps:
@@ -1154,17 +1154,15 @@ test.describe("Agent message submission", () => {
page,
page.getByTestId("assistant-message").last(),
);
const assistantMessageCount = await page.getByTestId("assistant-message").count();
const toolCallCount = await page.getByTestId("tool-call-badge").count();
await composer.press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
await expect(userMessage).toBeVisible();
await expect
.poll(async () => page.getByTestId("assistant-message").count())
.toBeGreaterThan(assistantMessageCount);
await expect
.poll(async () => page.getByTestId("tool-call-badge").count())
.toBeGreaterThan(toolCallCount);
await expect(
page.getByTestId("assistant-message").filter({
hasText: "I'll take a look at the scroll anchor behavior you described.",
}),
).toBeVisible();
await awaitToolCall(page, "Read");
await finishTimelineRowStabilityCheck();
});
@@ -1,3 +1,12 @@
import type { Page } from "@playwright/test";
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
import {
expectCompletedDiagram,
openDiagramFullscreen,
expectFullscreenDiagram,
} from "../support/helpers/diagram";
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
@@ -17,6 +26,10 @@ import {
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
import {
rememberTimelineViewport,
scrollTimelinePromptIntoView,
} from "../support/helpers/timeline-pagination";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -97,11 +110,11 @@ test("opens a timeline image in a zoomable lightbox", async ({
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.getByRole("button", { name: "Open image attachment" }).click();
await page.getByRole("button", { name: image.alt }).click();
const lightboxImage = page.getByTestId("attachment-lightbox-image");
const canvas = page.getByTestId("attachment-lightbox-canvas");
await expect(lightboxImage).toBeVisible();
await canvas.hover();
await page.getByTestId("zoomable-viewport-toolbar").hover();
const transformedContent = canvas.locator(":scope > div").first();
const initialBox = await transformedContent.boundingBox();
const canvasBox = await canvas.boundingBox();
@@ -133,7 +146,7 @@ test("opens a timeline image in a zoomable lightbox", async ({
await expectAgentReadyToInterrupt(page);
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
imageWorkspace: workspace,
page,
}) => {
@@ -143,119 +156,34 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
fileName: "reload-geometry-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
try {
delivery.release();
await emitSettledAssistantImage(workspace.client, imageAgent, image, delivery.url);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.addInitScript(
({ accessibleName }) => {
interface ImageGeometryEvent {
atMs: number;
complete: boolean;
height: number;
naturalHeight: number;
naturalWidth: number;
scrollTop: number;
width: number;
}
const state = {
accessibleName,
events: [] as ImageGeometryEvent[],
mutationObserver: null as MutationObserver | null,
resizeObserver: null as ResizeObserver | null,
target: null as Element | null,
};
const record = () => {
const target = state.target;
if (!target) return;
const imageElement =
target instanceof HTMLImageElement ? target : target.querySelector("img");
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
const rect = target.getBoundingClientRect();
const event: ImageGeometryEvent = {
atMs: performance.now(),
complete: imageElement?.complete ?? false,
height: rect.height,
naturalHeight: imageElement?.naturalHeight ?? 0,
naturalWidth: imageElement?.naturalWidth ?? 0,
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
width: rect.width,
};
const previous = state.events.at(-1);
if (
!previous ||
previous.complete !== event.complete ||
previous.height !== event.height ||
previous.naturalHeight !== event.naturalHeight ||
previous.naturalWidth !== event.naturalWidth ||
previous.scrollTop !== event.scrollTop ||
previous.width !== event.width
) {
state.events.push(event);
}
};
const inspect = () => {
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
(element) => element.getAttribute("aria-label") === accessibleName,
);
if (!target || target === state.target) {
record();
return;
}
state.target = target;
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(record);
state.resizeObserver.observe(target);
record();
};
const start = () => {
state.mutationObserver = new MutationObserver(inspect);
state.mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["src", "style"],
childList: true,
subtree: true,
});
inspect();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
(
window as unknown as {
__paseoReloadImageGeometry?: typeof state;
}
).__paseoReloadImageGeometry = state;
},
{ accessibleName: image.alt },
);
delivery.holdNextRequest();
await page.reload();
await delivery.waitUntilRequested();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await scrollTimelinePromptIntoView(
page,
"image-history-turn-2: emit 1 coalesced agent stream updates",
);
const beforeLoad = await rememberTimelineViewport(page);
const reservedHeight = (await rendered.boundingBox())?.height;
expect(reservedHeight).toBe(320);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.reload();
await expectAssistantImageRendered(page, image);
await page.waitForTimeout(500);
const events = await page.evaluate(() => {
const state = (
window as unknown as {
__paseoReloadImageGeometry?: { events: Array<unknown> };
}
).__paseoReloadImageGeometry;
if (!state) throw new Error("Reload image geometry observer was not installed");
return state.events;
});
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
const initialGeometry = geometryEvents.find((event) => event.height > 0);
if (!initialGeometry)
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
if (!expandedGeometry)
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
initialGeometry.scrollTop,
);
delivery.release();
await expectAssistantImageRendered(page, image);
expect((await rendered.boundingBox())?.height).toBe(reservedHeight);
await expect.poll(() => rememberTimelineViewport(page)).toEqual(beforeLoad);
} finally {
await delivery.close();
}
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
@@ -296,3 +224,222 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
response: "stress-update-0",
});
});
test("a later reply reads new bytes at the same workspace image path", async ({
imageWorkspace: workspace,
page,
}) => {
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
const first = await createSmallAssistantPng(workspace, {
alt: "Earlier screenshot",
fileName: "screenshot.png",
});
await emitSettledAssistantImage(workspace.client, agent, first);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, first);
const second = await createSmallAssistantPng(workspace, {
alt: "Updated screenshot",
fileName: "screenshot.png",
width: 2,
height: 3,
});
await emitSettledAssistantImage(workspace.client, agent, second);
await expectAssistantImageRendered(page, second);
await expectAssistantImageRendered(page, first);
});
test("pending assistant images reserve their decoded height", async ({
imageWorkspace: workspace,
page,
}, testInfo) => {
const image = await createSmallAssistantPng(workspace, {
alt: "Delayed remote image",
fileName: "delayed.png",
width: 32,
height: 24,
});
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
const { url } = delivery;
try {
await emitSettledAssistantImage(workspace.client, agent, image, url);
await openAssistantImageTimeline(page, agent);
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await delivery.waitUntilRequested();
const pendingBox = await rendered.boundingBox();
expect(pendingBox?.height).toBe(320);
await rendered.click();
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("pending-image.png") });
delivery.release();
await expect(rendered).toHaveAttribute("src", url);
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
} finally {
await delivery.close();
}
});
/** Serve the fixture's real PNG bytes; release controls when the decoder can finish. */
async function serveDelayedImage(file: string) {
let release!: () => void;
let ready: Promise<void>;
let requested!: () => void;
let requestStarted: Promise<void>;
const holdNextRequest = () => {
ready = new Promise<void>((resolve) => {
release = resolve;
});
requestStarted = new Promise<void>((resolve) => {
requested = resolve;
});
};
holdNextRequest();
const server = createServer((_request, response) => {
requested();
void ready.then(() => {
response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
return createReadStream(file).pipe(response);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Image server did not listen");
return {
url: `http://127.0.0.1:${address.port}/image.png`,
release: () => release(),
holdNextRequest,
waitUntilRequested: () => requestStarted,
close: async () => {
release();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
test("late workspace image completion preserves selected prose", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
const selected = await selectImageFixtureParagraphs(page);
release();
await expectAssistantImageRendered(page, image);
await expectSelectedParagraphsRetained(page, selected);
});
});
test("late workspace image completion keeps Mermaid fullscreen open", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Done"]);
release();
await expectAssistantImageRendered(page, image);
await expectFullscreenDiagram(page, ["Start", "Done"]);
});
});
async function withPendingWorkspaceImage(
page: Page,
workspace: SeededWorkspace,
run: (
image: Awaited<ReturnType<typeof createSmallAssistantPng>>,
release: () => void,
) => Promise<void>,
) {
const image = await createSmallAssistantPng(workspace, {
alt: "Late workspace image",
fileName: "late.png",
width: 32,
height: 24,
});
const source = [
"Paragraph one stays selected while the workspace image loads.",
"Paragraph two remains in this reply.",
"Paragraph three is the selection endpoint.",
"```mermaid\nflowchart LR\n Start --> Done\n```",
`![${image.alt}](${image.relativePath})`,
].join("\n\n");
const agent = await createSettledMockAgent(workspace, "Delayed workspace read", source);
await emitSettledAssistantImage(workspace.client, agent, image);
const gate = await delayFileReadResponse(page, image.relativePath);
let released = false;
const release = () => {
if (!released) {
released = true;
gate.release();
}
};
try {
await openAssistantImageTimeline(page, agent);
await gate.waitUntilHeld();
await expectCompletedDiagram(page, ["Start", "Done"]);
await run(image, release);
} finally {
release();
}
}
type SelectionProofWindow = Window & { __lateImageSelection?: { start: Node; end: Node } };
async function selectImageFixtureParagraphs(page: Page): Promise<string> {
const message = page.getByTestId("assistant-message");
await message
.getByText("Paragraph one stays selected while the workspace image loads.", { exact: true })
.scrollIntoViewIfNeeded();
const bounds = await message.evaluate((root) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let start: Text | undefined;
let end: Text | undefined;
while (walker.nextNode()) {
const text = walker.currentNode as Text;
if (text.data.startsWith("Paragraph one stays")) start = text;
if (text.data.startsWith("Paragraph three is")) end = text;
}
if (!start || !end) throw new Error("Selection paragraphs are missing");
(window as SelectionProofWindow).__lateImageSelection = { start, end };
const first = document.createRange();
first.selectNodeContents(start);
const last = document.createRange();
last.selectNodeContents(end);
return {
first: first.getBoundingClientRect().toJSON(),
last: last.getBoundingClientRect().toJSON(),
};
});
await page.mouse.move(bounds.first.x + 1, bounds.first.y + 5);
await page.mouse.down();
await page.mouse.move(bounds.last.right - 1, bounds.last.bottom - 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("Paragraph one");
expect(selected).toContain("Paragraph three");
return selected;
}
async function expectSelectedParagraphsRetained(page: Page, selected: string) {
await expect
.poll(() =>
page.evaluate(() => {
const proof = (window as SelectionProofWindow).__lateImageSelection;
return {
startConnected: proof?.start.isConnected,
endConnected: proof?.end.isConnected,
text: window.getSelection()?.toString(),
};
}),
)
.toEqual({ startConnected: true, endConnected: true, text: selected });
}
@@ -10,6 +10,8 @@ import {
reportScrollJumps,
scrollCadences,
withVariedTimeline,
withStreamingReader,
holdParagraphThroughCompletion,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
@@ -202,3 +204,22 @@ for (const cadence of scrollCadences) {
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
await expectImageSpaceReserved(page, testInfo);
});
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
await withStreamingReader(async (agent) => {
const reading = await holdParagraphThroughCompletion(page, agent);
expect(
reading.textGrowth,
"the held reader must observe an actively growing response",
).toBeGreaterThan(1000);
expect(
reading.maxFollowGap,
"follow-bottom must catch each streamed commit before the next frame",
).toBeLessThanOrEqual(1);
expect(
reading.maxDrift,
"streaming must preserve the held paragraph's viewport position",
).toBeLessThanOrEqual(1);
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
});
});
@@ -2,7 +2,9 @@ import { expect, test } from "../support/fixtures";
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
test("caps a newline-heavy assistant message on web before rendering markdown", async ({
page,
}) => {
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
const agent = await seedMockAgentWorkspace({
@@ -73,9 +73,14 @@ const ASSISTANT_MARKDOWN = [
].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"",
"www.example.com stays",
"[www.example.com](http://www.example.com) stays",
)
.replace(
"`https://example.com/generated` stays",
"[`https://example.com/generated`](https://example.com/generated) stays",
)
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
@@ -98,8 +103,7 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
}
/**
* 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.
* Locate assistant rows by source-message identity.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
@@ -173,7 +177,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
const blocks = assistantMessageBlocks(page);
const target = blocks.getByText(text);
await target.scrollIntoViewIfNeeded();
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
const frame = await blocks.locator("img").boundingBox();
const end = await target.evaluate((element, selectedText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
@@ -192,7 +196,8 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
if (!frame) {
throw new Error("Expected a rendered image");
}
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.mouse.move(end.left + 1, end.y - 14);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 10 });
await page.mouse.up();
@@ -210,18 +215,12 @@ async function selectAssistantListItemFromMarker(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.locator(listTag)
.filter({ hasText: itemText })
.locator(':scope > [data-paseo-markdown-tag="li"]')
.locator(":scope > li")
.filter({ hasText: itemText });
await item.evaluate((element, selectedEndText) => {
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
const markerText = marker?.firstChild;
if (!(markerText instanceof Text)) {
throw new Error("Expected rendered list marker text");
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let endNode: Text | null = null;
let endOffset = -1;
@@ -239,7 +238,7 @@ async function selectAssistantListItemFromMarker(
}
const range = document.createRange();
range.setStart(markerText, 0);
range.setStart(element, 0);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
@@ -254,7 +253,7 @@ async function doubleClickAssistantMarkdownText(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.locator(tag)
.filter({ hasText: text })
.dblclick({ position: { x: 5, y: 5 } });
}
@@ -276,39 +275,23 @@ async function selectAssistantTextRange(
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;
let endOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
}
if (startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (offset >= 0) {
endNode = textNode;
endOffset = offset + selectedRange.endText.length;
break;
}
}
textNode = walker.nextNode();
}
if (!startNode || !endNode) {
const text = nodes.map((node) => node.data).join("");
const start = text.indexOf(selectedRange.startText);
const end = text.indexOf(selectedRange.endText, start);
if (start < 0 || end < 0)
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
}
const boundary = (offset: number, isEnd = false): [Text, number] => {
for (const node of nodes) {
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
offset -= node.length;
}
throw new Error("Selection extends beyond message");
};
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
range.setStart(...boundary(start));
range.setEnd(...boundary(end + selectedRange.endText.length, true));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
@@ -428,16 +411,12 @@ test("copying an assistant selection preserves Markdown structure and links", as
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text });
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage
.locator('[data-paseo-markdown-tag="code"]')
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
await selectStructuredClipboardFixture(page);
await copySelection(page);
@@ -466,7 +445,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).not.toContain(
expect(clipboard.html).toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
);
expect(clipboard.html).toContain('<code class="language-typescript">');
+13 -11
View File
@@ -33,9 +33,10 @@ const STREAMED_RESPONSE = [
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.
// One message taller than the viewport, exercising navigation between distant
// matches inside the same rendered row.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const VIRTUALIZED_TURN_COUNT = 60;
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}`;
@@ -322,11 +323,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
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.
// The first-paragraph hit must remain searchable while the same message grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
// One message, two paragraphs, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
@@ -346,12 +347,14 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
for (let index = 0; index < VIRTUALIZED_TURN_COUNT; 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 ${VIRTUALIZED_TURN_COUNT - 1}`, { exact: true }),
).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
@@ -360,9 +363,8 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
}
}
// 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.
// Find must count distant paragraphs in one source message as well as messages
// outside the mounted history window. Next must not skip a same-message hit.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
@@ -370,9 +372,9 @@ test("counts every occurrence of a virtualized message and steps between them",
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
@@ -310,7 +310,7 @@ async function sendFeedback(page: Page, count: number) {
}
async function expectFeedbackSent(page: Page, recipient: string) {
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
await expect(page.getByTestId("app-toast")).toHaveText(`Feedback sent to ${recipient}`);
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
}
@@ -340,6 +340,9 @@ test.describe("send review feedback", () => {
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Please simplify this.");
await captureFeedback(page, "single");
await expect(
page.getByText("Feedback sent to Composer diff stat", { exact: true }),
).toHaveCount(0);
});
await test.step("Return to the untouched composer draft", async () => {
await showWideLayout(page);
@@ -375,11 +378,7 @@ test.describe("send review feedback", () => {
).toBeDisabled();
expect(gate.requestCount()).toBe(1);
gate.accept();
await expect(
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
exact: true,
}),
).toBeVisible();
await expectFeedbackSent(page, "Composer diff stat");
});
} finally {
await workspace.cleanup();
@@ -72,10 +72,10 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("img", { name, exact: true });
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
await expect
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBeGreaterThan(0);
}
@@ -221,7 +221,7 @@ test.describe("CodeMirror workspace file editing", () => {
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
} finally {
await workspace.cleanup();
}
@@ -0,0 +1,45 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
await link.click();
}
async function expectFolderContents(page: Page) {
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
).toBeVisible();
}
for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "folder-link-repro-",
title: "Folder link reproduction",
initialPrompt: "Show the folder link",
featureValues: { mockAssistantResponse: `[Open folder](./${folder})` },
});
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
);
});
await openAgentRoute(page, session);
await openReportedFolderLink(page, "Open folder");
await expectFolderContents(page);
} finally {
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
await session.cleanup();
}
});
}
@@ -0,0 +1,42 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
async function openProjectHome(page: Page) {
await page.goto("/open-project");
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
}
async function scrollToHomeFooter(page: Page) {
await page.mouse.move(160, 350);
await page.mouse.wheel(0, 1500);
}
async function expectHomeActionsReachable(page: Page) {
const providers = page.getByTestId("open-project-setup-providers");
const community = page.getByRole("button", { name: "Community", exact: true });
await expect(providers).toBeInViewport({ ratio: 1 });
await expect(community).toBeInViewport({ ratio: 1 });
const providerBounds = await providers.boundingBox();
const communityBounds = await community.boundingBox();
expect(providerBounds).not.toBeNull();
expect(communityBounds).not.toBeNull();
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
}
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 320, height: 568 });
await openProjectHome(page);
await scrollToHomeFooter(page);
await expectHomeActionsReachable(page);
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
});
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openProjectHome(page);
await expectHomeActionsReachable(page);
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
"-webkit-app-region",
"none",
);
});
@@ -1,15 +1,9 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The plan card has to pass its own markdown parser. Drop that prop and
// react-native-markdown-display silently supplies one with `typographer: true`
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
// No unit test can catch that, because the defect lives in the JSX.
//
// Nothing here is provider-specific: the mock provider drives it, and the
// behavior is the same for every agent.
// Literal shell commands and copyright markers must survive Markdown rendering.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
@@ -25,6 +19,7 @@ test.describe("Plan card markdown", () => {
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard.locator(".enrm-text")).toBeVisible();
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
@@ -35,3 +30,161 @@ test.describe("Plan card markdown", () => {
}
});
});
test("chat and file preview share the library renderer and resolve file links", async ({
page,
}, testInfo) => {
await withSharedMarkdownFiles(page, async () => {
const message = page.getByTestId("assistant-message");
await expect(message).toHaveCount(1);
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
await expect(message.locator("blockquote")).toContainText("A quote");
await expect(message.getByRole("table")).toContainText("42");
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
await expect(message.getByRole("cell").nth(1)).toHaveText("");
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
await expect(message.locator("p div")).toHaveCount(0);
await openSharedMarkdownFile(page, "README.md");
const preview = page.getByTestId("markdown-preview-frame");
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
await expect(preview.getByRole("table")).toContainText("42");
await returnToSharedMarkdownChat(page);
await expectFileLinkTooltip(
page,
message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
"packages/app/src/说明.md:2-3",
);
await openSharedMarkdownFile(page, "src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "Space file");
await expect(
page.getByText("Explicit link opened the space filename.", { exact: true }),
).toBeVisible();
await openSharedMarkdownFile(page, "my%20file.ts");
await expect(
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
).toBeVisible();
await testInfo.attach("shared-renderer", {
body: await page.screenshot(),
contentType: "image/png",
});
});
});
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
await link.hover();
await expect(page.getByRole("tooltip")).toHaveText(path);
}
async function withSharedMarkdownFiles(page: Page, run: () => Promise<void>) {
const { writeFile, mkdir } = await import("node:fs/promises");
const source =
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
const session = await seedMockAgentWorkspace({
repoPrefix: "shared-markdown-",
title: "Shared Markdown",
featureValues: { mockAssistantResponse: source },
initialPrompt: "Show Markdown",
});
try {
await writeFile(`${session.cwd}/README.md`, source);
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
await writeFile(
`${session.cwd}/my%20file.ts`,
"Inline code opened the literal percent filename.",
);
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
await writeFile(
`${session.cwd}/packages/app/src/说明.md`,
"# Nested file\n\nUnicode suffix lookup succeeded.",
);
await session.client.waitForFinish(session.agentId, 30_000);
// A cold render must work even when a separate parser chunk cannot be fetched.
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
await openAgentRoute(page, session);
await run();
} finally {
await session.cleanup();
}
}
async function returnToSharedMarkdownChat(page: Page) {
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
}
async function openSharedMarkdownFile(page: Page, name: string) {
await returnToSharedMarkdownChat(page);
await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
}
test("linked inline code keeps code typography and wraps long paths", async ({
page,
}, testInfo) => {
const longPath =
"packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-code-style-",
title: "Inline code style",
featureValues: {
mockAssistantResponse:
"Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
longPath +
"`.",
},
initialPrompt: "Show inline code",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
const measurements = await measureInlineCode(message);
await testInfo.attach("inline-code-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
expect(measurements.codes).toHaveLength(4);
const [plain, file, url, long] = measurements.codes;
for (const linked of [file, url, long]) {
expect(linked.fontSize).toBe(plain.fontSize);
expect(linked.fontFamily).toBe(plain.fontFamily);
expect(linked.backgroundColor).toBe(plain.backgroundColor);
expect(linked.color).toBe(linked.anchorColor);
expect(linked.color).not.toBe(plain.color);
}
expect(long.lines).toBeGreaterThan(1);
expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
} finally {
await session.cleanup();
}
});
async function measureInlineCode(message: Locator) {
return message.evaluate((element) => {
// Constrain the paragraph to exercise an inline path at a line boundary.
const paragraph = element.querySelector("p")!;
paragraph.style.width = "300px";
const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
const style = getComputedStyle(code);
const range = document.createRange();
range.selectNodeContents(code);
const rects = Array.from(range.getClientRects());
return {
text: code.textContent,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
backgroundColor: style.backgroundColor,
color: style.color,
anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
lines: new Set(rects.map((rect) => rect.top)).size,
right: Math.max(...rects.map((rect) => rect.right)),
};
});
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
@@ -21,6 +21,15 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Show background",
]);
await closeMenuSheet(page);
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
@@ -29,6 +29,16 @@ test.describe("Sidebar project filter", () => {
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeSidebarDisplayPreferences(page);
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
@@ -0,0 +1,71 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Harmless commands: this journey opens the profile menu without launching them.
test.use({
e2eDaemonConfig: {
version: 1,
daemon: {
terminalProfiles: [
{ id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
{ id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
{ id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
],
},
},
});
function profileIcon(page: Page, name: string) {
return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
}
async function expectBundledArtwork(page: Page, name: string, artwork: string) {
const icon = profileIcon(page, name);
await expect(icon).toBeVisible();
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, artwork);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await expect(icon).toHaveAttribute("width", "14");
await expect(icon).toHaveAttribute("height", "14");
const terminalColor = await page
.getByRole("menuitem", { name: "Terminal", exact: true })
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
}
async function expectUnknownArtworkFallback(page: Page) {
const icon = profileIcon(page, "Unknown");
await expect(icon).toBeVisible();
await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
}
test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openLaunchMenu(page);
await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
await expectUnknownArtworkFallback(page);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,18 @@
context platform=ios timeout=60000 retries=0
# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
open sh.paseo.debug
wait "editable=true" 60000
fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
@@ -0,0 +1,13 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
close
@@ -52,13 +52,11 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise<void
await expect(messages).toHaveCount(count);
for (let index = 0; index < count; index++) {
const message = messages.nth(index);
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
await expect(message).toHaveText("Current temperature: 25°C. Read Paseo docs.");
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/docs");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
"Current temperature",
);
await expect(message.locator("strong")).toHaveText("Current temperature");
}
}
@@ -575,20 +575,25 @@ export async function holdAssistantStream(page: Page, agentId: string) {
});
});
const forwardUntil = async (done: (text: string) => boolean): Promise<void> => {
while (!done(forwardedText)) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
};
return {
waitForInitialTimeline: () => initialTimeline,
showUntil: (text: string) => forwardUntil((forwarded) => forwarded.includes(text)),
async showThrough(prefix: string): Promise<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
await forwardUntil((text) => text.length >= prefix.length);
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
@@ -26,16 +26,13 @@ export interface AssistantImageFixture {
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
input: { alt: string; fileName: string; width?: number; height?: number },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
const width = input.width ?? 1;
const height = input.height ?? 1;
const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
}
export async function createNearTenMegabyteAssistantPng(
@@ -61,6 +58,7 @@ export async function createNearTenMegabyteAssistantPng(
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
markdown?: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -69,6 +67,7 @@ export async function createSettledMockAgent(
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
featureValues: markdown === undefined ? undefined : { mockAssistantResponse: markdown },
});
await workspace.client.waitForAgentUpsert(
agent.id,
@@ -124,7 +123,7 @@ export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
@@ -172,14 +171,14 @@ export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
await expect(page.getByRole("button", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
const rendered = page.getByRole("button", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
@@ -253,8 +252,8 @@ async function beginVisibleImageStabilityObservation(
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
const imageVisible = Array.from(document.querySelectorAll("img[alt]")).some(
(element) => element.getAttribute("alt") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
@@ -45,11 +45,8 @@ export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): P
export async function expectUnfinishedBold(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
await expect(bold).toContainText("Bold");
await expect(message.locator(".enrm-text")).toContainText("Bold");
await expect(message).not.toContainText("stays bold");
await expect(bold).toHaveCSS("font-weight", "500");
await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(
@@ -61,7 +58,6 @@ export async function expectUnfinishedLink(
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
await expect(message).toContainText("Paseo docs");
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
}
@@ -95,8 +91,5 @@ async function expectCompletedMarkdown(page: Page): Promise<void> {
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/documentation");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
"font-weight",
"500",
);
await expect(message.locator("strong")).toHaveCSS("font-weight", "700");
}
@@ -1,8 +1,9 @@
import { writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { seedMockAgentWorkspace, openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
import { openAgentTimeline, expectTimelinePromptVisible } from "./timeline-pagination";
import { holdAssistantStream } from "./agent-timeline-gate";
export const scrollCadences = [
{ name: "slow", delta: 160, intervalMs: 50, steps: 1000 },
@@ -44,6 +45,120 @@ interface TimelinePage {
at: number;
}
export async function withStreamingReader(run: (agent: MockAgentWorkspace) => Promise<void>) {
const section = "Read this paragraph while the response continues below. ".repeat(8);
const response = [
"```mermaid\nflowchart LR\n Start --> Review --> Done\n```",
"```mermaid\nflowchart LR\n Nested --> Finished\n```",
...Array.from({ length: 40 }, (_, index) => `## Long section ${index}\n\n${section}`),
"READER-STREAM-SETTLED",
].join("\n\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "held-stream-reader-",
title: "Held streaming paragraph",
featureValues: {
mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 1,
},
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
export async function holdParagraphThroughCompletion(page: Page, agent: MockAgentWorkspace) {
const stream = await holdAssistantStream(page, agent.agentId);
await openAgentRoute(page, agent);
await page.getByRole("textbox", { name: "Message agent...", exact: true }).waitFor();
const scroll = page.getByTestId("agent-chat-scroll");
const following = await scroll.evaluateHandle((viewport) => {
const result = { stopped: false, maxGap: 0 };
const frame = () => {
if (result.stopped) return;
result.maxGap = Math.max(
result.maxGap,
viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
);
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
return result;
});
await agent.client.sendAgentMessage(agent.agentId, "Stream the mixed response.");
await stream.showUntil("Long section 12\n");
await page
.getByRole("heading", { name: "Long section 12", exact: true })
.waitFor({ state: "attached" });
// Exercise a reader whose producer has already finished; delivery must be gated.
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByTestId("mermaid-viewport")).toHaveCount(2);
await expect(page.getByTestId("mermaid-viewport").nth(0)).toHaveCSS("opacity", "1");
await expect(page.getByTestId("mermaid-viewport").nth(1)).toHaveCSS("opacity", "1");
const maxFollowGap = await following.evaluate((result) => {
result.stopped = true;
return result.maxGap;
});
const bounds = await scroll.boundingBox();
if (!bounds) throw new Error("Reader viewport is missing");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.wheel(0, -1600);
await page.waitForTimeout(150);
const paragraph = page.getByRole("heading", { name: "Long section 3", exact: true });
await paragraph.waitFor({ state: "attached" });
const recording = await paragraph.evaluateHandle((heading) => {
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
throw new Error("The stream completed before the reader detached");
}
const viewport = document.querySelector<HTMLElement>('[data-testid="agent-chat-scroll"]')!;
const clip = viewport.getBoundingClientRect();
viewport.scrollTop += heading.getBoundingClientRect().y - (clip.top + clip.height / 2);
const baseline = heading.getBoundingClientRect().y;
const initialTextLength = heading.closest('[data-testid="assistant-message"]')!.textContent!
.length;
const result = { maxDrift: 0, retained: true, textGrowth: 0, done: false };
let stableAt = performance.now();
let signature = "";
const frame = (now: number) => {
result.retained &&= heading.isConnected;
const current = [...document.querySelectorAll("h2")].find(
(n) => n.textContent === "Long section 3",
);
const textLength =
current?.closest('[data-testid="assistant-message"]')?.textContent?.length ?? 0;
result.textGrowth = Math.max(result.textGrowth, textLength - initialTextLength);
if (current)
result.maxDrift = Math.max(
result.maxDrift,
Math.abs(current.getBoundingClientRect().y - baseline),
);
const next = `${viewport.scrollHeight}:${viewport.scrollTop}:${current?.getBoundingClientRect().y}`;
if (next !== signature) {
signature = next;
stableAt = now;
}
const complete =
document.body.textContent?.includes("Worked for") &&
document.body.textContent.includes("READER-STREAM-SETTLED") &&
!document.querySelector('[data-testid="turn-working-indicator"]');
if (complete && now - stableAt >= 750) {
result.done = true;
return;
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
return result;
});
await expect(
page.getByTestId("scroll-to-bottom-button").filter({ visible: true }).first(),
).toBeVisible();
stream.release();
await page.waitForFunction((result) => result.done, recording);
return { ...(await recording.jsonValue()), maxFollowGap };
}
/** Real HTTP image responses, with intrinsic dimensions unavailable until the response arrives. */
export async function withVariedTimeline(
run: (agent: MockAgentWorkspace, newestPrompt: string) => Promise<void>,
@@ -495,7 +610,7 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, "Show the delayed image");
const image = page.getByRole("img", { name: "Reserved image" }).first();
const image = page.getByRole("button", { name: "Reserved image", exact: true }).first();
await expect(image).toBeVisible();
const before = await image.boundingBox();
await attachTimelineScreenshot(page, testInfo, "image-placeholder");
@@ -512,8 +627,9 @@ export async function expectImageSpaceReserved(page: Page, testInfo: TestInfo):
.toBe(240);
await expect
.poll(async () => {
const box = await image.boundingBox();
return box ? Math.round((box.width / box.height) * 100) : 0;
return image.evaluate((element: HTMLImageElement) =>
Math.round((element.naturalWidth / element.naturalHeight) * 100),
);
})
.toBe(150);
const after = await image.boundingBox();
+32 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.2",
"version": "0.12.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -104,7 +104,11 @@
"i18next": "^26.3.0",
"lucide-react-native": "^1.50.0",
"markdown-it": "^10.0.0",
"mdast-util-from-markdown": "2.0.3",
"mdast-util-gfm-table": "^2.0.0",
"mermaid": "^11.16.0",
"micromark-extension-gfm-table": "^2.1.2",
"micromark-util-decode-string": "2.0.1",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -114,15 +118,14 @@
"react-native": "0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.3.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-uitextview": "^2.2.0",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
@@ -164,6 +167,32 @@
"wrangler": "^4.105.0",
"ws": "^8.20.0"
},
"enriched-markdown": {
"enableMath": false,
"enableCodeHighlight": true,
"codeHighlightLanguages": [
"json",
"html",
"css",
"markdown",
"yaml",
"go",
"java",
"javascript",
"python",
"c",
"rust",
"bash",
"typescript",
"tsx",
"cpp",
"swift",
"php",
"ruby",
"c-sharp"
],
"enableVideo": false
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -0,0 +1,27 @@
// Regenerate from the repo root: node packages/app/scripts/generate-markdown-image-placeholder.mjs
// sharp is already provided by the development dependency tree; it is not used at runtime.
import { readFile, writeFile } from "node:fs/promises";
import sharp from "sharp";
// Lucide Image (ISC), lucide-react-native/dist/esm/icons/image.mjs.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<svg x="293.333333" y="293.333333" width="53.333333" height="53.333333" viewBox="0 0 24 24">
<g fill="none" stroke="#808080" opacity="0.6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</g>
</svg>
</svg>`;
const png = await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
const uri = `data:image/png;base64,${png.toString("base64")}`;
const sourceFile = new URL("../src/components/markdown/source.ts", import.meta.url);
const source = await readFile(sourceFile, "utf8");
await writeFile(
sourceFile,
source.replace(
/export const PENDING_MARKDOWN_IMAGE =\s*"[^"]*";/,
`export const PENDING_MARKDOWN_IMAGE =\n "${uri}";`,
),
);
console.log(`Generated 640×640 Markdown image placeholder: ${png.length} bytes`);
@@ -65,3 +65,14 @@ it("matches whitespace across text nodes without inspecting hidden link destinat
"world\nline",
]);
});
it("keeps semantic Markdown paragraphs separate while finding inline matches", () => {
const row = content(
"<p>İ😀hello <strong>world</strong>; hello <em>world</em>.</p><p>another paragraph</p>",
);
expect(findRenderedMatches(row, "hello world").map((range) => range.toString())).toEqual([
"hello world",
"hello world",
]);
expect(findRenderedMatches(row, ".another")).toEqual([]);
});
@@ -1,10 +1,10 @@
const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"]
.map((tag) => `[data-paseo-markdown-tag="${tag}"]`)
.flatMap((tag) => [tag, `[data-paseo-markdown-tag="${tag}"]`])
.join(",");
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. */
/** Rendered rows carrying a source message identity, in reading order. */
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
if (!root) return [];
return Array.from(
@@ -98,3 +98,23 @@ function highlightedRow(): string | undefined {
);
return row?.dataset.historyRowId;
}
it("waits for an asynchronous renderer before counting its matches", async () => {
const root = transcript("message-1", ["needle"]);
const content = root.querySelector('[data-message-text="true"]')!;
content.setAttribute("aria-busy", "true");
let settled = false;
const reveal = viewport(root, {})
.reveal("message-1", "needle", 0, new AbortController().signal)
.then((result) => {
settled = true;
return result;
});
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
expect(settled).toBe(false);
content.textContent = "needle and another needle";
content.removeAttribute("aria-busy");
expect(await reveal).toEqual({ occurrence: 0, count: 2 });
});
@@ -21,8 +21,7 @@ export function createFindViewport({
clear() {
CSS.highlights.delete(highlightName);
},
// 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.
// The source message owns navigation; the rendered occurrence owns its position.
reveal(messageId, query, occurrence, signal) {
return new Promise((resolve, reject) => {
let frame = 0;
@@ -61,6 +60,10 @@ export function createFindViewport({
frame = requestAnimationFrame(poll);
return;
}
if (rows.some((row) => row.querySelector('[aria-busy="true"]'))) {
frame = requestAnimationFrame(poll);
return;
}
const ranges = findMessageMatches(getRoot(), messageId, query);
const index =
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
@@ -49,17 +49,6 @@ function assistantMessage(id: string): Extract<StreamItem, { kind: "assistant_me
};
}
function assistantBlock(
id: string,
blockIndex: number,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
...assistantMessage(id),
blockGroupId: "response",
blockIndex,
};
}
function timingFor(assistantId: string): Map<string, TurnTiming> {
return new Map([
[
@@ -109,30 +98,4 @@ describe("turn footer spacing", () => {
expect(assistantLayout?.assistantSpacing).toBe("compactBottom");
},
);
it.each([
["web", false],
["web", true],
["native", false],
["native", true],
] as const)(
"compacts both edges of the final split paragraph on %s when active=%s",
(platform, isTurnActive) => {
const strategy = strategyFor(platform);
const first = assistantBlock("first", 0);
const last = assistantBlock("last", 1);
const layout = layoutStream({
strategy,
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
expect(firstLayout?.assistantSpacing).toBe("compactBottom");
expect(lastLayout?.assistantSpacing).toBe("compactBoth");
},
);
});
+4 -5
View File
@@ -25,7 +25,7 @@ function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "use
function assistantMessage(
id: string,
seed: number,
block?: { groupId: string; index: number },
_block?: { groupId: string; index: number },
turnId?: string,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
@@ -33,7 +33,6 @@ function assistantMessage(
id,
text: id,
timestamp: timestamp(seed),
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
...(turnId ? { turnId } : {}),
};
}
@@ -318,7 +317,7 @@ describe("layoutStream", () => {
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("compacts assistant block spacing across the history and live-head boundary", () => {
it("keeps separate messages spaced across history and the live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
@@ -328,8 +327,8 @@ describe("layoutStream", () => {
timingIds: [historyBlock.id, headBlock.id],
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth");
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("default");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBottom");
});
it.each(["web", "android"] as const)(
+2 -4
View File
@@ -1,6 +1,6 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
@@ -311,10 +311,8 @@ function layoutSegmentItem(
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
const assistantSpacing = getAssistantBlockSpacing({
const assistantSpacing = getAssistantMessageSpacing({
item,
aboveItem,
belowItem,
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
});
@@ -1,7 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
@@ -10,9 +8,11 @@ import {
type ToolCallItem,
type UserMessageItem,
} from "@/types/stream";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
const runtime = {
paseo: {},
@@ -123,6 +123,25 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
return [...result.tail, ...result.head];
}
describe("whole-message Markdown presentation", () => {
it("keeps paragraphs, fences and quotes in one progressively updated row", () => {
const present = createStreamPresentation();
const text = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
for (const length of [10, 30, text.length]) {
const source = hydrateStreamState([
{ event: assistant(text.slice(0, length)), timestamp: new Date(1000) },
]);
const result = present({
...presentationOptions,
tail: [],
head: source,
transform: undefined,
});
expect(rows(result)).toEqual(source);
}
});
});
describe("stream presentation through installed plugins", () => {
it("offers every source tool call to an installed transformer in Overview mode", () => {
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
@@ -198,26 +217,17 @@ describe("stream presentation through installed plugins", () => {
{ text: "Header\n\nBody", phase: "streaming" },
]);
expect(rows(harness.render(transform))).toHaveLength(1);
expect(rows(harness.render()).map((item) => item.kind)).toEqual([
"assistant_message",
"assistant_message",
]);
expect(rows(harness.render()).map((item) => item.kind)).toEqual(["assistant_message"]);
});
it("keeps completed native blocks stable while the last block grows and finishes", () => {
it("retains the complete message when streaming finishes", () => {
const harness = streamHarness();
const first = harness.send(assistant("Intro\n\n![Image](image.png)"));
const growing = harness.send(assistant("\n\nAfter\n"));
const last = harness.send(assistant("- item"));
harness.send(assistant("Intro\n\n![Image](image.png)"));
const last = harness.send(assistant("\n\nAfter\n- item"));
const completed = harness.send({ type: "turn_completed", provider: "claude" });
expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
expect(first.head).toMatchObject([{ text: "![Image](image.png)", blockIndex: 1 }]);
expect(growing.tail[0]).toBe(first.tail[0]);
expect(growing.tail[1]).toBe(first.head[0]);
expect(last.tail).toBe(growing.tail);
expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
expect(completed.tail).toEqual(rows(last));
expect(completed.tail[2]).toBe(last.head[0]);
expect(last.tail).toEqual([]);
expect(last.head).toMatchObject([{ text: "Intro\n\n![Image](image.png)\n\nAfter\n- item" }]);
expect(completed.tail).toEqual(last.head);
expect(completed.head).toEqual([]);
});
@@ -225,8 +235,8 @@ describe("stream presentation through installed plugins", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
const result = harness.send(assistant("\n\nconst b = 2;"));
expect(result.tail).toMatchObject([{ text: "Intro" }]);
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
expect(result.tail).toEqual([]);
expect(result.head).toMatchObject([{ text: "Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
@@ -244,14 +254,13 @@ describe("stream presentation through installed plugins", () => {
// 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", () => {
it("preserves source identity through history and the live head", () => {
const source = hydrateStreamState([
{
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()({
@@ -261,20 +270,7 @@ describe("stream presentation through installed plugins", () => {
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.",
},
]);
expect(present("tail")).toEqual(source);
const blocks = (items: StreamItem[]) =>
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
@@ -282,7 +278,7 @@ describe("stream presentation through installed plugins", () => {
// 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", () => {
it("gives a streamed message the same id as its hydrated text", () => {
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) {
@@ -304,7 +300,7 @@ describe("stream presentation through installed plugins", () => {
);
});
it("repoints a block at the canonical source that replaced the streamed one", () => {
it("uses the canonical source that replaced the streamed message", () => {
const harness = streamHarness();
harness.send(assistant("Streamed answer"));
const streamed = rows(harness.render());
@@ -338,15 +334,8 @@ describe("stream presentation through installed plugins", () => {
head: [],
transform: undefined,
});
expect(result.tail).toMatchObject([
{
id: `${messageId}:block:0`,
blockGroupId: messageId,
blockIndex: 0,
text: "Only one paragraph.",
},
]);
expect(result.tail[0]!.id).not.toBe(messageId);
expect(result.tail).toEqual(source);
expect(result.tail[0]!.id).toBe(messageId);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
@@ -563,13 +552,7 @@ describe("timeline presentation", () => {
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
},
];
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)).toEqual(items);
expect(projectTimelineItems(items)[0]).toBe(items[0]);
});
+12 -118
View File
@@ -1,13 +1,12 @@
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
interface PresentationInput {
tail: StreamItem[];
@@ -17,42 +16,13 @@ interface PresentationInput {
isTurnActive: boolean;
}
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
return previous.length === next.length && previous.every((item, index) => item === next[index])
? previous
: 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. A
* plugin row a timeline transformer projected answers for the item it replaced, whose
* id is what the chat outline and find hold.
*/
/** Find and scrolling address a plugin row by the source it replaces. */
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
if (item.kind === "plugin") return item.sourceId ?? item.id;
return 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. */
/** Source messages reach plugins before display projection. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
@@ -71,115 +41,39 @@ export function createStreamPresentation() {
return projected;
}
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
let liveSources = new Map<string, AssistantMessageItem>();
let historySource: StreamItem[] | undefined;
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let displayTail: StreamItem[] = [];
let displayHistory: StreamItem[] | undefined;
let promotedRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
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];
const cached = blocksBySource.get(item);
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previous = previousSource && blocksBySource.get(previousSource);
// Parse only the growing last block on append, as the old live reducer did.
// Canonical text replacements are parsed afresh instead of joining fragments.
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
let prefix: AssistantMessageItem[] = [];
let growingText = item.text;
if (isAppend) {
prefix = previous.slice(0, -1);
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
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()) {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
blocks.push(existing);
continue;
}
blocks.push({
...item,
id,
blockGroupId: item.id,
blockIndex: index,
text: blockText,
});
}
blocksBySource.set(item, blocks);
return blocks;
function presentRows(item: StreamItem): StreamItem[] {
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
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>(
nativeBlocks,
(item) => presentRows(item),
);
historySource = input.tail;
historyTransform = input.transform;
}
const head: StreamItem[] = [];
const promoted: StreamItem[] = [];
const nextLiveSources = new Map<string, AssistantMessageItem>();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
const blocks = nativeBlocks(item);
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
if (item.kind === "assistant_message" && blocks.length > 1) {
promoted.push(...blocks.slice(0, -1));
head.push(blocks[blocks.length - 1]!);
} else {
head.push(...blocks);
}
const blocks = presentRows(item);
head.push(...blocks);
}
liveSources = nextLiveSources;
const nextPromoted = retainItems(promotedRows, promoted);
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
displayTail = historyRows;
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
displayHistory = historyRows;
promotedRows = nextPromoted;
}
if (preparedTail !== displayTail || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, displayTail);
preparedTail = displayTail;
if (preparedTail !== historyRows || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, historyRows);
preparedTail = historyRows;
preparedLevel = input.level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: displayTail,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
+13 -128
View File
@@ -1,135 +1,20 @@
import { describe, expect, it } from "vitest";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
function assistantBlock(params: {
id: string;
blockGroupId: string;
blockIndex: number;
text?: string;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: params.blockGroupId,
blockIndex: params.blockIndex,
text: params.text ?? "",
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "cmd",
result: null,
status: "executing",
},
},
};
}
describe("isSameAssistantBlockGroup", () => {
it("returns true for two assistant blocks with the same blockGroupId", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
});
it("returns false for blocks from different groups", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
});
it("returns false when one item is not an assistant_message", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
const tc = toolCallBlock("tc-1");
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
});
it("returns false for null neighbors", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
});
});
describe("getAssistantBlockSpacing", () => {
it("returns default for non-assistant items", () => {
const tc = toolCallBlock("tc-1");
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
"default",
);
});
it("returns default when no same-group neighbors exist", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
});
it("compacts the bottom edge when the turn footer follows the assistant block", () => {
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
expect(
getAssistantBlockSpacing({
item: a,
aboveItem: null,
belowItem: null,
hasFooterBelow: true,
}),
).toBe("compactBottom");
});
it("returns compactTop when the item above is in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
"compactTop",
);
});
it("returns compactBottom when the item below is in the same block group", () => {
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
const message: StreamItem = {
kind: "assistant_message",
id: "m",
text: "First\n\nSecond",
timestamp: new Date(0),
};
describe("whole-message spacing", () => {
it("compacts only the gap to its turn footer", () => {
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
expect(getAssistantMessageSpacing({ item: message, hasFooterBelow: true })).toBe(
"compactBottom",
);
});
it("returns compactBoth when both neighbors are in the same block group", () => {
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
"compactBoth",
);
});
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
text: "First paragraph",
});
const headBlock = assistantBlock({
id: "group-1:head",
blockGroupId: "group-1",
blockIndex: 1,
text: "Second paragraph",
});
expect(
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
).toBe("compactBottom");
expect(
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
).toBe("compactTop");
it("keeps separate messages separated", () => {
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
});
});
+5 -30
View File
@@ -1,35 +1,13 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
other: StreamItem | null | undefined;
}): boolean {
return (
params.item?.kind === "assistant_message" &&
params.other?.kind === "assistant_message" &&
params.item.blockGroupId !== undefined &&
params.item.blockGroupId === params.other.blockGroupId
);
}
export function getAssistantBlockSpacing(params: {
export function getAssistantMessageSpacing(params: {
item: StreamItem;
aboveItem: StreamItem | null | undefined;
belowItem: StreamItem | null | undefined;
hasFooterBelow?: boolean;
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
if (params.item.kind !== "assistant_message") {
return "default";
}
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
const compactBottom =
params.hasFooterBelow ||
isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
if (compactTop && compactBottom) return "compactBoth";
if (compactTop) return "compactTop";
if (compactBottom) return "compactBottom";
return "default";
}): "default" | "compactBottom" {
return params.item.kind === "assistant_message" && params.hasFooterBelow
? "compactBottom"
: "default";
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
@@ -62,8 +40,5 @@ export function getGapBetweenStreamItems(
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
}
@@ -591,8 +591,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
initialNumToRender={12}
windowSize={10}
// A history row can contain an entire long reply. Keep synchronous native
// Markdown measurement bounded while FlatList fills the surrounding window.
initialNumToRender={2}
maxToRenderPerBatch={2}
windowSize={5}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
+14 -4
View File
@@ -307,7 +307,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
olderHistoryProgressKey,
scrollEnabled,
contentMaxWidth,
imageContext,
isMobileBreakpoint,
} = props;
const historyVirtualized = useRevisedHistoryRows(
@@ -455,7 +454,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const row = segments.historyVirtualized[index];
const measured = row && measuredRowHeights.current.get(row.id);
if (measured && measured.width === contentWidth) return measured.height;
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth, imageContext }) : 120;
return row ? estimateStreamItemHeight({ item: row, contentMaxWidth }) : 120;
},
observeElementOffset: observeVirtualOffset,
rangeExtractor,
@@ -979,14 +978,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
// ResizeObserver runs before paint. Following in the next frame exposes
// the newly grown row at the old offset for one painted frame.
forceStickToBottom();
});
observer.observe(scrollContainer);
if (contentNode) {
observer.observe(contentNode);
}
// Paced reveal commits inside a message without rerendering this viewport.
// Observe those commits too, so the next frame sees the grown content at the
// bottom instead of waiting for the resize notification after animation frames.
const contentChanges = new MutationObserver(() => {
if (isActiveRef.current && followOutputRef.current) forceStickToBottom();
});
if (contentNode)
contentChanges.observe(contentNode, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
contentChanges.disconnect();
};
}, [
reconcileReadingPosition,
@@ -994,7 +1004,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
]);
@@ -1,4 +1,3 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
@@ -95,7 +94,6 @@ export interface StreamRenderInput {
forwardListContentContainerStyle: StyleProp<ViewStyle>;
// The chat column's max width. The web viewport estimates unmeasured row heights with it.
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export interface ResolveStreamRenderStrategyInput {
+62 -82
View File
@@ -87,13 +87,10 @@ import {
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -218,12 +215,13 @@ function renderListEmptyComponent(input: {
);
}
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
// Live and retained rows use the same boundary so completion preserves the message
// subtree, including measured diagrams and selections. FlatList recreates history
// cells with fresh refs and shifted indexes on data changes. This boundary is
// what stops that churn: a row renders again only when its stream item identity, its layout item
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
const HistoryStreamRow = memo(function HistoryStreamRow({
const StreamRow = memo(function StreamRow({
layoutItem,
renderStreamItem,
}: {
@@ -234,7 +232,7 @@ const HistoryStreamRow = memo(function HistoryStreamRow({
return <>{renderStreamItem(layoutItem)}</>;
});
function renderHistoryStreamItem(input: {
function renderStreamRow(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
@@ -244,7 +242,7 @@ function renderHistoryStreamItem(input: {
return null;
}
return (
<HistoryStreamRow
<StreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
@@ -252,18 +250,6 @@ function renderHistoryStreamItem(input: {
);
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -398,7 +384,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
const { revealDirectory } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
@@ -437,63 +423,53 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedToolCallGroupIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
(location: WorkspaceFileLocation, disposition: OpenFileDisposition) => {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ location, disposition });
} else if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
},
);
const openInlineDirectory = useStableEvent((directoryPath: string) => {
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
});
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
workspaceRoot,
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive, agentId]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
}
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
if (context.workspaceId) {
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
openExplorerSidebarView({
isCompact: isMobile,
workspaceKey: buildWorkspaceTabPersistenceKey({
serverId: resolvedServerId,
workspaceId: context.workspaceId ?? "",
}),
checkout: {
serverId: resolvedServerId,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
},
view: "files",
});
if (isActive) void inlinePathOpener.open(target, disposition);
},
);
@@ -530,7 +506,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// cell-window and turn-lifecycle renders from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useRetainedPanelActive();
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
@@ -1030,7 +1005,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
renderStreamRow({
item,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
@@ -1051,7 +1026,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// to be a new object on every text-chunk flush.
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
(item: StreamItem) =>
renderLiveHeadStreamItem({
renderStreamRow({
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
@@ -1137,7 +1112,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
imageContext: { serverId: resolvedServerId, workspaceRoot },
})}
</MessageOuterSpacingProvider>
<ChatOutlineRail
@@ -1543,11 +1517,17 @@ function PermissionRequestCard({
);
if (request.kind === "question") {
const responseFailed =
permissionMutation.isError &&
permissionMutation.variables?.agentId === permission.agentId &&
permissionMutation.variables?.requestId === permission.request.id;
const responseError = responseFailed ? t("message.question.responseFailed") : null;
return (
<QuestionFormCard
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
responseError={responseError}
/>
);
}
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
import type { StreamItem } from "@/types/stream";
import {
clearAssistantImageMetadataCache,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
setAssistantMessageHeight,
clearAssistantMessageHeightEstimateCache,
} from "@/utils/assistant-message-height-estimate";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
@@ -161,56 +161,18 @@ describe("estimateStreamItemHeight", () => {
);
});
it("uses local image dimensions only from the row's server and workspace", () => {
clearAssistantImageMetadataCache();
const imageContext = { serverId: "server-one", workspaceRoot: "/workspace/one" };
setAssistantImageMetadata(
{ source: "portrait.png", ...imageContext },
{ width: 800, height: 1600 },
);
it("uses the renderer's whole-message measurement at the measured width", () => {
clearAssistantMessageHeightEstimateCache();
const item: StreamItem = {
kind: "assistant_message",
id: "local-image",
text: "![Portrait](portrait.png)",
id: "measured",
text: "First paragraph\n\n![Image](image.png)",
timestamp: createTimestamp(2),
};
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608, imageContext })).toBe(1264);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, workspaceRoot: "/workspace/two" },
}),
).toBe(464);
expect(
estimateStreamItemHeight({
item,
contentMaxWidth: 608,
imageContext: { ...imageContext, serverId: "server-two" },
}),
).toBe(464);
});
it("uses cached assistant image metadata when available", () => {
clearAssistantImageMetadataCache();
setAssistantImageMetadata(
{
source: "https://example.com/tall.png",
},
{ width: 800, height: 1600 },
);
const item: StreamItem = {
kind: "assistant_message",
id: "a-image",
text: "Look at this\n\n![Screenshot](https://example.com/tall.png)",
timestamp: createTimestamp(2),
};
expect(
estimateStreamItemHeight({ item, contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH }),
).toBeGreaterThan(220);
setAssistantMessageHeight({ markdown: item.text, width: 608, height: 640 });
expect(estimateStreamItemHeight({ item, contentMaxWidth: 608 })).toBe(640);
expect(estimateStreamItemHeight({ item, contentMaxWidth: 320 })).toBe(220);
clearAssistantMessageHeightEstimateCache();
});
});
@@ -1,4 +1,3 @@
import type { AssistantImageContext } from "@/utils/assistant-image-metadata";
import type { StreamItem } from "@/types/stream";
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
import {
@@ -47,13 +46,11 @@ export interface WebVirtualizedHistoryWindow {
interface StreamItemHeightEstimateInput {
item: StreamItem;
contentMaxWidth: number;
imageContext?: AssistantImageContext;
}
export function estimateStreamItemHeight({
item,
contentMaxWidth,
imageContext,
}: StreamItemHeightEstimateInput): number {
switch (item.kind) {
case "user_message":
@@ -63,7 +60,6 @@ export function estimateStreamItemHeight({
estimateAssistantMessageHeightFromCache({
markdown: item.text,
contentMaxWidth,
imageContext,
}) ?? 220
);
case "tool_call":
@@ -0,0 +1,57 @@
export const ASSISTANT_FILE_EXTENSIONS: ReadonlySet<string> = new Set([
"astro",
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
@@ -1,9 +1,3 @@
export {
AssistantInlineCodePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
@@ -11,7 +5,10 @@ export {
} from "./parse";
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions } from "./use-file-link";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { createInlinePathOpener } from "./path-open";
@@ -1,24 +0,0 @@
import { createContext, useContext } from "react";
import type { TextProps } from "react-native";
// Carries a link's press handler down to the leaf text spans that render its
// label. On iOS an assistant link is a nested UITextView span, and
// react-native-uitextview only attaches onPress to the *string* children it
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
// MarkdownInheritedText spans markdown produces for link text) pass through
// untouched, so an onPress placed on the wrapping span never reaches a tappable
// native node. Threading the handler through context lets each leaf span hand
// onPress to its own string children, where the native tap recognizer can find
// it. Provided only on iOS (Android/web links tap fine via their own paths).
export interface AssistantLinkPress {
onPress: () => void;
accessibilityRole?: TextProps["accessibilityRole"];
}
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
export function useAssistantLinkPress(): AssistantLinkPress | null {
return useContext(AssistantLinkPressContext);
}
@@ -1,213 +0,0 @@
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownLinkText } from "@/components/markdown/link-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
}
const MARKDOWN_CODE_LINK_DATASET = {
...CODE_SURFACE_DATASET,
...markdownCopyDataSet.code,
} as const;
export function AssistantMarkdownLink({
source,
style,
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const { target, onHoverIn, onPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
[target, workspaceRoot],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
);
const unwrapForMarkdownCopy = source.sourceType === "inline-code" || source.markup === "linkify";
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
// inside the paragraph's native UITextView, and a plain <Text> nested there
// is not hoisted into a UITextViewChild, so its text is silently dropped
// (the link disappears). The span composes correctly and stays selectable.
//
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
// children it turns into RNUITextViewChild nodes — the element children that
// markdown emits for link text pass through untouched, so an onPress placed
// here never reaches a tappable native node. We thread it down through
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
// own string children, where the native tap recognizer can find it. iOS
// only: Android forwards onPress through nested <Text> already, and web uses
// the <a> path below.
const span = (
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</MarkdownTextSpan>
);
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
{Platform.OS === "ios" ? (
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
) : (
span
)}
</FileLinkHoverTooltip>
);
}
const anchor = (
<a
{...(unwrapForMarkdownCopy ? { "data-paseo-markdown-unwrap": "true" } : {})}
href={source.href}
title={source.title}
onClickCapture={preventAnchorNavigation}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<MarkdownLinkText
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={style}
onPress={onPress}
onHoverIn={onHoverIn}
>
{children}
</MarkdownLinkText>
</a>
);
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
}
interface AssistantMarkdownCodeLinkProps {
source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
children: ReactNode;
}
export function AssistantMarkdownCodeLink({
source,
inheritedStyles,
codeInlineStyle,
linkStyle,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
() => [inheritedStyles, codeInlineStyle, linkStyle],
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink source={source} style={style} monoSurface>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
>
{content}
</AssistantMarkdownCodeLink>
);
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
display: "inline-flex" as ViewStyle["display"],
};
function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) {
return children;
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
{filePath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
</TooltipContent>
) : null}
</Tooltip>
);
}
const LINK_ANCHOR_STYLE: CSSProperties = {
display: "contents",
color: "inherit",
textDecoration: "none",
};
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
event.preventDefault();
}
const styles = StyleSheet.create((theme) => ({
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
+1 -58
View File
@@ -1,3 +1,4 @@
import { ASSISTANT_FILE_EXTENSIONS } from "./extensions";
import { isAbsolutePath } from "@/utils/path";
function safeDecodeURIComponent(value: string): string {
@@ -20,64 +21,6 @@ const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?
const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/;
const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/;
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
const ASSISTANT_FILE_EXTENSIONS = new Set([
"astro",
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/**
@@ -0,0 +1,104 @@
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import { resolveInlinePathOpen } from "./path-open";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("distinguishes dotted and extensionless folders from files using the filesystem", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-"));
roots.push(workspaceRoot);
await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true });
await mkdir(path.join(workspaceRoot, "docs/reports.v1"));
await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n");
await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n");
const client = {
listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)),
};
for (const file of ["docs/guides", "docs/reports.v1"]) {
await expect(
resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }),
).resolves.toEqual({ kind: "directory", directoryPath: file });
}
for (const file of ["Makefile", "README.md", "missing.md"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }),
).resolves.toEqual({ kind: "file" });
}
});
it("preserves explicit directory destinations and file line references", async () => {
const workspaceRoot = "/repo";
await expect(
resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }),
).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" });
await expect(
resolveInlinePathOpen({
file: "docs/guides",
directory: "docs",
lineStart: 12,
workspaceRoot,
client: null,
}),
).resolves.toEqual({ kind: "file" });
});
it("keeps unavailable probes and external paths on the existing file-open path", async () => {
for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) {
await expect(
resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }),
).resolves.toEqual({ kind: "file" });
}
});
it("ignores an older probe after a newer file link opens", async () => {
const { createInlinePathOpener } = await import("./path-open");
let rejectProbe!: (error: Error) => void;
const pendingProbe = new Promise<never>((_, reject) => {
rejectProbe = reject;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
rejectProbe(new Error("Not a directory"));
await first;
expect(opened).toEqual(["second.md"]);
});
it("ignores a pending probe when its view is left", async () => {
const { createInlinePathOpener } = await import("./path-open");
let resolveProbe!: (value: unknown) => void;
const pendingProbe = new Promise((resolve) => {
resolveProbe = resolve;
});
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe },
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
resolveProbe({});
await pending;
expect(opened).toEqual([]);
});
@@ -0,0 +1,74 @@
import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse";
import {
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type OpenFileDisposition,
} from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path";
interface PathOpenInput {
file?: string;
directory: string;
lineStart?: number;
workspaceRoot: string;
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
isAbsolutePath(input.file) ||
!input.workspaceRoot.trim() ||
!input.client
) {
return { kind: "file" };
}
try {
await input.client.listDirectory(input.workspaceRoot, input.file);
} catch {
return { kind: "file" };
}
return { kind: "directory", directoryPath: input.file };
}
export function createInlinePathOpener(input: {
workspaceRoot: string;
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
}) {
let generation = 0;
return {
async open(target: InlinePathTarget, disposition: OpenFileDisposition) {
const ownGeneration = ++generation;
const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot);
if (!normalized) return;
const destination = await resolveInlinePathOpen({
...normalized,
lineStart: target.lineStart,
workspaceRoot: input.workspaceRoot,
client: input.client,
});
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (location) input.openFile(location, disposition);
}
},
cancel() {
generation += 1;
},
};
}
@@ -14,6 +14,7 @@ import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
readFile?: import("@getpaseo/client/internal/daemon-client").DaemonClient["readFile"];
getDirectorySuggestions: GetDirectorySuggestions;
}
@@ -78,10 +79,16 @@ export function AssistantFileLinkResolverProvider({
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
const context = useContext(AssistantFileLinkResolverContext);
if (!context) {
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
}
return context;
return context ?? EMPTY_CONTEXT;
}
export type { AssistantFileLinkContext };
const EMPTY_CONTEXT: AssistantFileLinkResolverContextValue = {
configRef: { current: {} },
getDirectorySuggestions: async () => ({ entries: [], error: null }),
};
export function useAssistantFileLinkEnvironment() {
return useAssistantFileLinkResolverContext().configRef.current;
}
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
@@ -18,6 +19,7 @@ import {
export interface UseFileLinkResult {
target: InlinePathTarget | null;
tooltipPath: string | null;
onHoverIn: () => void;
onPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
@@ -134,7 +136,11 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(() => ({ target, onHoverIn, onPress, open }), [target, onHoverIn, onPress, open]);
const tooltipPath = target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null;
return useMemo(
() => ({ target, tooltipPath, onHoverIn, onPress, open }),
[target, tooltipPath, onHoverIn, onPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
@@ -1,120 +0,0 @@
import { describe, expect, it } from "vitest";
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
describe("assistant image lifecycle", () => {
it("keeps preview URL recreation in the public loading state", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:first",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:first",
aspectRatio: 1.5,
});
const recreating = transitionAssistantImageLifecycle(loaded, {
type: "preview_released",
});
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
});
it("publishes loaded only after the recreated URL loads", () => {
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:recreated",
aspectRatio: 1.5,
});
expect(recreating).toEqual({
status: "loading",
uri: "blob:recreated",
aspectRatio: 1.5,
});
const recreated = transitionAssistantImageLifecycle(recreating, {
type: "image_loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
expect(recreated).toEqual({
status: "loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
});
it("does not reset a loaded image when the same preview is reported again", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:current",
aspectRatio: 1.5,
});
const repeated = transitionAssistantImageLifecycle(loaded, {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.5,
});
expect(repeated).toBe(loaded);
});
it("publishes failed for a terminal image failure on the current URI", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const failed = transitionAssistantImageLifecycle(loading, {
type: "failed",
uri: "blob:current",
message: "Unable to load image preview.",
});
expect(failed).toEqual({
status: "failed",
message: "Unable to load image preview.",
});
});
it("ignores a stale load callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.25,
});
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
type: "image_loaded",
uri: "blob:released",
aspectRatio: 2,
});
expect(afterStaleLoad).toEqual(current);
});
it("ignores a stale error callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const afterStaleError = transitionAssistantImageLifecycle(current, {
type: "failed",
uri: "blob:released",
message: "Image unavailable",
});
expect(afterStaleError).toEqual(current);
});
});
@@ -1,46 +0,0 @@
export type AssistantImageLifecycle =
| { status: "loading"; uri: string | null; aspectRatio: number | null }
| { status: "loaded"; uri: string; aspectRatio: number }
| { status: "failed"; message: string };
export type AssistantImageLifecycleEvent =
| { type: "preview_created"; uri: string; aspectRatio: number | null }
| { type: "preview_released" }
| { type: "image_loaded"; uri: string; aspectRatio: number }
| { type: "failed"; uri: string; message: string };
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
return { status: "loading", uri: null, aspectRatio: null };
}
export function transitionAssistantImageLifecycle(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
if (event.type === "image_loaded") {
if (state.status !== "loading" || state.uri !== event.uri) {
return state;
}
return {
status: "loaded",
uri: event.uri,
aspectRatio: event.aspectRatio,
};
}
if (event.type === "failed") {
if (state.status === "failed" || state.uri !== event.uri) {
return state;
}
return { status: "failed", message: event.message };
}
if (event.type === "preview_created") {
if (state.status === "loaded" && state.uri === event.uri) {
return state;
}
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
}
if (event.type === "preview_released") {
return { status: "loading", uri: null, aspectRatio: null };
}
return state;
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
constructor(private readonly dimensions: { width: number; height: number } | null) {}
read(): { width: number; height: number } | null {
return this.dimensions;
}
}
describe("assistant image load dimensions", () => {
it("prefers native source dimensions", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 640, height: 320 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 640, height: 320 });
});
it("uses browser event dimensions when native dimensions are absent", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 0, height: 0 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 800, height: 400 });
});
it("falls back to the rendered image adapter", () => {
const renderedImage = { id: "rendered-image" };
const dimensions = resolveAssistantImageLoadDimensions({
target: null,
renderedImage,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 900, height: 600 });
});
});
@@ -1,45 +0,0 @@
export interface AssistantImageDimensions {
width: number;
height: number;
}
interface AssistantImageDimensionCandidate {
width?: unknown;
height?: unknown;
}
export interface AssistantImageRenderedDimensionsReader {
read(renderedImage: unknown): AssistantImageDimensions | null;
}
function readPositiveDimensions(
candidate: AssistantImageDimensionCandidate | null | undefined,
): AssistantImageDimensions | null {
if (
typeof candidate?.width !== "number" ||
typeof candidate.height !== "number" ||
candidate.width <= 0 ||
candidate.height <= 0
) {
return null;
}
return { width: candidate.width, height: candidate.height };
}
export function resolveAssistantImageLoadDimensions(input: {
source?: AssistantImageDimensionCandidate | null;
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
renderedImage: unknown;
renderedDimensions: AssistantImageRenderedDimensionsReader;
}): AssistantImageDimensions | null {
const source = readPositiveDimensions(input.source);
if (source) {
return source;
}
const target = readPositiveDimensions({
width: input.target?.naturalWidth,
height: input.target?.naturalHeight,
});
return target ?? input.renderedDimensions.read(input.renderedImage);
}
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { runAssistantImageOperationWithRetry } from "./retry";
describe("assistant image retry", () => {
it("recovers from transient failures while the image remains mounted", async () => {
const waits: number[] = [];
let attempts = 0;
const result = await runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("transient");
}
return "loaded";
},
delaysMs: [10, 20, 30],
wait: async (delayMs) => {
waits.push(delayMs);
},
});
expect({ attempts, waits, result }).toEqual({
attempts: 3,
waits: [10, 20],
result: "loaded",
});
});
it("stops retrying after the image is released", async () => {
let stopped = false;
let attempts = 0;
await expect(
runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
throw new Error("transient");
},
delaysMs: [10, 20],
shouldStop: () => stopped,
wait: async () => {
stopped = true;
},
}),
).rejects.toThrow("transient");
expect(attempts).toBe(1);
});
});
-31
View File
@@ -1,31 +0,0 @@
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
export async function runAssistantImageOperationWithRetry<T>(input: {
operation: () => Promise<T>;
delaysMs?: readonly number[];
shouldStop?: () => boolean;
wait?: (delayMs: number) => Promise<void>;
}): Promise<T> {
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
const shouldStop = input.shouldStop ?? (() => false);
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
});
for (let attempt = 0; ; attempt += 1) {
try {
return await input.operation();
} catch (error) {
const delay = delays[attempt];
if (delay === undefined || shouldStop()) {
throw error;
}
await wait(delay);
if (shouldStop()) {
throw error;
}
}
}
}
@@ -1,544 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ImageLoadEvent } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import {
persistAttachmentFromBytes,
persistAttachmentFromDataUrl,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageFileAcquisition,
type AssistantImageAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
uri: string;
onRef: (instance: unknown) => void;
onLoad: (event: ImageLoadEvent) => void;
onError: () => void;
}
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
read(renderedImage) {
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
return null;
}
const image = renderedImage.querySelector("img");
return image && image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: null;
},
};
export type AssistantImageResult =
| {
status: "loading";
binding: AssistantImageRenderBinding | null;
aspectRatio: number | null;
}
| {
status: "loaded";
binding: AssistantImageRenderBinding;
aspectRatio: number;
}
| { status: "failed"; message: string };
interface UseAssistantImageInput {
source: string;
occurrenceKey: string;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}
type PreviewUrlState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; uri: string }
| { status: "failed"; error: unknown };
type AttachmentAcquisitionState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; attachment: AttachmentMetadata }
| { status: "failed"; error: unknown };
interface DataImage {
mimeType: string;
base64: string;
cacheKey: string;
}
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
capacity: 500,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
interface CachedPreviewUrl {
attachment: AttachmentMetadata;
uri: string;
}
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
capacity: 500,
onRetain:
({ attachment, uri }) =>
() => {
void releaseAttachmentPreviewUrl({ attachment, url: uri });
},
});
const LOADED_IMAGE_CACHE_CAPACITY = 500;
const loadedImageCache = new Map<string, number>();
function getLoadedImageAspectRatio(uri: string): number | null {
const aspectRatio = loadedImageCache.get(uri);
if (aspectRatio === undefined) {
return null;
}
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
return aspectRatio;
}
function rememberLoadedImage(uri: string, aspectRatio: number): void {
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
return;
}
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
if (leastRecentlyUsedUri !== undefined) {
loadedImageCache.delete(leastRecentlyUsedUri);
}
}
function useAttachmentAcquisition(
acquisition: AssistantImageAcquisition | null,
): AttachmentAcquisitionState {
const acquisitionKey = acquisition?.key ?? null;
const [entry, setEntry] = useState<{
key: string | null;
state: AttachmentAcquisitionState;
}>(() => {
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
return {
key: acquisitionKey,
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
};
});
useEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
if (!acquisition || !acquisitionKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = attachmentAcquisitionCache.acquireRetained(
acquisitionKey,
acquisition.locate,
);
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: acquisitionKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const attachment = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [acquisition, acquisitionKey]);
if (!acquisitionKey) {
return { status: "waiting" };
}
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
if (cached) {
return { status: "loaded", attachment: cached };
}
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AssistantImageAcquisition | null {
if (!input.dataImage) {
return null;
}
const { dataImage, source } = input;
return {
key: dataImage.cacheKey,
locate: async () =>
await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
contentKey: dataImage.cacheKey,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
}),
};
}
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const previewKey =
id && storageType && storageKey && mimeType
? `${id}:${storageType}:${storageKey}:${mimeType}`
: null;
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
return {
key: previewKey,
state: { status: "waiting" },
};
});
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
useLayoutEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
const current = getCurrentAttachment();
if (!current || !previewKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
}));
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: previewKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const preview = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: previewKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [getCurrentAttachment, previewKey]);
if (!previewKey) {
return { status: "waiting" };
}
return entry.key === previewKey ? entry.state : { status: "waiting" };
}
function lifecycleReducer(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
return transitionAssistantImageLifecycle(state, event);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function getAcquisitionFailure(input: {
hasResolution: boolean;
isFileSource: boolean;
isDataImage: boolean;
fileAttachment: AttachmentAcquisitionState;
dataImageAttachment: AttachmentAcquisitionState;
preview: PreviewUrlState;
hasDirectUri: boolean;
fallbackMessage: string;
}): AssistantImageResult | null {
if (!input.hasResolution) {
return { status: "failed", message: input.fallbackMessage };
}
if (input.isFileSource && input.fileAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
};
}
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
};
}
if (!input.hasDirectUri && input.preview.status === "failed") {
return {
status: "failed",
message: errorMessage(input.preview.error, input.fallbackMessage),
};
}
return null;
}
export function useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
return createAssistantImageFileAcquisition({
port,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
);
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
const filePreview = usePreviewUrl(
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
);
const dataImagePreview = usePreviewUrl(
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const preview = dataImage ? dataImagePreview : filePreview;
const previewUri = preview.status === "loaded" ? preview.uri : null;
const uri = directUri ?? previewUri;
const cachedAspectRatio = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId })?.aspectRatio ?? null,
[serverId, source, workspaceRoot],
);
const [lifecycle, dispatchLifecycle] = useReducer(
lifecycleReducer,
uri,
(initialUri): AssistantImageLifecycle => {
if (initialUri) {
const aspectRatio = getLoadedImageAspectRatio(initialUri);
if (aspectRatio !== null) {
return { status: "loaded", uri: initialUri, aspectRatio };
}
}
return createAssistantImageLifecycle();
},
);
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
if (event.type === "image_loaded") {
rememberLoadedImage(event.uri, event.aspectRatio);
} else if (event.type === "failed" && event.uri) {
loadedImageCache.delete(event.uri);
}
dispatchLifecycle(event);
}, []);
const renderedImageRef = useRef<unknown>(null);
const handleImageRef = useCallback((instance: unknown) => {
renderedImageRef.current = instance;
}, []);
useEffect(() => {
if (!uri) {
dispatch({ type: "preview_released" });
return;
}
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedAspectRatio,
});
}, [cachedAspectRatio, dispatch, uri]);
const handleImageError = useCallback(() => {
if (uri) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
}
}, [dispatch, t, uri]);
const handleImageLoad = useCallback(
(event: ImageLoadEvent) => {
if (!uri) {
return;
}
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const dimensions = resolveAssistantImageLoadDimensions({
source: nativeEvent.source,
target: nativeEvent.target,
renderedImage: renderedImageRef.current,
renderedDimensions,
});
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;
const aspectRatio = metadata?.aspectRatio ?? cachedAspectRatio;
if (!aspectRatio) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
return;
}
dispatch({ type: "image_loaded", uri, aspectRatio });
},
[cachedAspectRatio, dispatch, serverId, source, t, uri, workspaceRoot],
);
const acquisitionFailure = getAcquisitionFailure({
hasResolution: resolution !== null,
isFileSource: resolution?.kind === "file_rpc",
isDataImage: dataImage !== null,
fileAttachment,
dataImageAttachment,
preview,
hasDirectUri: directUri !== null,
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
});
if (acquisitionFailure) {
return acquisitionFailure;
}
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
let binding: AssistantImageRenderBinding | null = null;
if (hasCurrentLifecycleUri && lifecycle.uri) {
binding = {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
};
}
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
return {
status: "loaded",
binding: {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
},
aspectRatio: lifecycle.aspectRatio,
};
}
if (lifecycle.status === "failed") {
return lifecycle;
}
return {
status: "loading",
binding,
aspectRatio: (hasCurrentLifecycleUri ? lifecycle.aspectRatio : null) ?? cachedAspectRatio,
};
}
@@ -760,3 +760,58 @@ describe("assistant selection copy inside highlighted code", () => {
);
});
});
describe("semantic library output", () => {
it("copies a partial strong span as plain text", () => {
const host = document.createElement("div");
host.innerHTML =
'<div data-testid="assistant-message"><p>Before <strong>bold text</strong> after</p></div>';
document.body.append(host);
const text = host.querySelector("strong")!.firstChild!;
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 4);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe("old");
expect(createAssistantSelectionClipboardContent(selection)?.html).not.toContain("<strong>");
host.remove();
});
});
it("copies native semantic ordered lists with their original numbers", () => {
const host = document.createElement("div");
host.innerHTML =
'<div data-testid="assistant-message"><ol start="3"><li>Third item</li><li>Fourth item</li></ol></div>';
document.body.append(host);
const selection = selectNodeContents(host.querySelector("ol")!);
const content = createAssistantSelectionClipboardContent(selection);
expect(content?.plainText).toContain("3. Third item");
expect(content?.plainText).toContain("4. Fourth item");
host.remove();
});
it("copies a selection inside semantic inline code without Markdown escaping", () => {
const message = mountFixture();
message.innerHTML = "<p>Run <code>apply_patch</code> now.</p>";
const code = fixtureElement(message, "code");
expect(createAssistantSelectionClipboardContent(selectText(code, 0, 11))?.plainText).toBe(
"apply_patch",
);
});
it("copies a workspace image path instead of its temporary display URL", () => {
const message = mountFixture();
const sources = JSON.stringify({ "blob:preview": "assets/chart.png" });
message.innerHTML = '<div><img src="blob:preview" alt="chart"></div>';
message.firstElementChild!.setAttribute("data-paseo-markdown-image-sources", sources);
const selection = window.getSelection()!;
const range = document.createRange();
range.selectNode(message.querySelector("img")!);
selection.removeAllRanges();
selection.addRange(range);
expect(createAssistantSelectionClipboardContent(selection)?.plainText).toBe(
"![chart](assets/chart.png)",
);
});
@@ -6,6 +6,7 @@ import {
type MarkdownClipboardContent,
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE,
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_ALT_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
@@ -22,9 +23,38 @@ 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"]`;
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
const CODE_BLOCK_SELECTOR = `pre, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, code, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
const IMAGE_FRAME_SELECTOR = `img, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
const SEMANTIC_TAGS =
"p,blockquote,br,code,pre,h1,h2,h3,h4,h5,h6,hr,img,li,ol,ul,strong,em,s,del,table,thead,tbody,tr,th,td";
function semanticTag(element: Element): string | null {
return (
element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) ??
(element.matches(SEMANTIC_TAGS) ? element.tagName.toLowerCase() : null)
);
}
function copySemanticAttributes(original: Element, copy: Element): void {
const tag = semanticTag(original);
if (!tag || original.hasAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE)) return;
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, tag);
if (tag === "ol")
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, original.getAttribute("start") ?? "1");
if (tag === "img") {
const url = original.getAttribute("src") ?? "";
const encodedSources = original
.closest(`[${MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE}]`)
?.getAttribute(MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE);
const sources: Record<string, string> = encodedSources ? JSON.parse(encodedSources) : {};
copy.setAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE, sources[url] ?? url);
copy.setAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE, original.getAttribute("alt") ?? "");
}
if (tag === "td" || tag === "th") {
const align = (original as HTMLElement).style.textAlign;
if (align) copy.setAttribute(MARKDOWN_COPY_ALIGN_ATTRIBUTE, align);
}
}
const turndown = new TurndownService({
bulletListMarker: "-",
@@ -247,6 +277,7 @@ function cloneMarkdownSelection(range: Range, message: Element): Node {
while (ancestor && ancestor !== message) {
if (shouldPreserveSemanticElement(range, ancestor)) {
const wrapper = ancestor.cloneNode(false) as Element;
copySemanticAttributes(ancestor, wrapper);
normalizeOrderedListStart(wrapper, ancestor, range);
wrapper.append(selected);
selected = wrapper;
@@ -294,25 +325,26 @@ function nodeAtPath(root: Node, path: number[]): Node {
}
function removeUnselectedSemantics(range: Range, source: Element, clone: Element): void {
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a`;
const selector = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}], a, ${SEMANTIC_TAGS}`;
const originals = Array.from(source.querySelectorAll(selector));
const copies = Array.from(clone.querySelectorAll(selector));
originals.forEach((original, index) => {
const copy = copies[index];
if (copy) copySemanticAttributes(original, copy);
if (!copy || shouldPreserveSemanticElement(range, original)) {
if (copy) {
normalizeOrderedListStart(copy, original, range);
}
return;
}
const tag = original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
const tag = semanticTag(original);
if (tag && isBlockBoundary(tag)) {
copy.setAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE, "p");
return;
}
copy.removeAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (copy.tagName === "A") {
if (copy.matches(SEMANTIC_TAGS) || copy.tagName === "A") {
copy.setAttribute(MARKDOWN_COPY_UNWRAP_ATTRIBUTE, "true");
}
});
@@ -322,7 +354,7 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (!range.intersectsNode(element)) {
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
const tag = semanticTag(element);
// An image has no part to select, so touching it selects all of it.
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
@@ -340,8 +372,16 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
(item) => hasSelectedListMarker(range, item) || hasSelectedAllContents(range, item, true),
);
}
if (tag === "li" && hasSelectedListMarker(range, element)) {
return true;
if (tag === "li") {
if (hasSelectedListMarker(range, element)) return true;
// CSS list markers are outside text nodes. Starting inside a semantic item
// excludes its marker even when all of the label's characters are selected.
if (
element.tagName === "LI" &&
range.startContainer !== element &&
element.contains(range.startContainer)
)
return false;
}
return hasSelectedAllContents(range, element, tag === "li");
}
@@ -358,7 +398,7 @@ function selectionStaysInsideElement(range: Range, element: Element): boolean {
function selectedListItems(list: Element, range: Range): Element[] {
return Array.from(list.children).filter(
(child) =>
child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li" &&
semanticTag(child) === "li" &&
(child.contains(range.startContainer) ||
child.contains(range.endContainer) ||
hasSelectedAllContents(range, child, true)),
@@ -368,7 +408,7 @@ function selectedListItems(list: Element, range: Range): Element[] {
function hasSelectedListMarker(range: Range, item: Element): boolean {
const marker = item.querySelector(`:scope > [${MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE}]`);
if (!marker) {
return false;
return item.tagName === "LI" && range.startContainer === item && range.startOffset === 0;
}
const markerContents = document.createRange();
@@ -391,18 +431,20 @@ function hasSelectedListMarker(range: Range, item: Element): boolean {
}
function normalizeOrderedListStart(copy: Element, original: Element, range: Range): void {
if (original.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) !== "ol") {
if (semanticTag(original) !== "ol") {
return;
}
const firstSelectedItem = selectedListItems(original, range)[0];
if (!firstSelectedItem) {
return;
}
const items = Array.from(original.children).filter(
(child) => child.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE) === "li",
);
const items = Array.from(original.children).filter((child) => semanticTag(child) === "li");
const omittedItems = items.indexOf(firstSelectedItem);
const originalStart = Number(original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ?? 1);
const originalStart = Number(
original.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE) ??
original.getAttribute("start") ??
1,
);
copy.setAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE, String(originalStart + omittedItems));
}
@@ -19,44 +19,11 @@ export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
export const TRAILING_CODE_LINE_BREAKS = /(\r?\n[ \t]*)+$/;
export const markdownCopyDataSet = {
blockquote: { paseoMarkdownTag: "blockquote" },
br: { paseoMarkdownTag: "br" },
code: { paseoMarkdownTag: "code" },
h1: { paseoMarkdownTag: "h1" },
h2: { paseoMarkdownTag: "h2" },
h3: { paseoMarkdownTag: "h3" },
h4: { paseoMarkdownTag: "h4" },
h5: { paseoMarkdownTag: "h5" },
h6: { paseoMarkdownTag: "h6" },
hr: { paseoMarkdownTag: "hr" },
ignore: { paseoMarkdownIgnore: "true" },
li: { paseoMarkdownTag: "li" },
listMarker: { paseoMarkdownIgnore: "true", paseoMarkdownListMarker: "true" },
ol: { paseoMarkdownTag: "ol" },
p: { paseoMarkdownTag: "p" },
pre: { paseoMarkdownTag: "pre" },
s: { paseoMarkdownTag: "s" },
strong: { paseoMarkdownTag: "strong" },
em: { paseoMarkdownTag: "em" },
table: { paseoMarkdownTag: "table" },
tbody: { paseoMarkdownTag: "tbody" },
td: { paseoMarkdownTag: "td" },
th: { paseoMarkdownTag: "th" },
thead: { paseoMarkdownTag: "thead" },
tr: { paseoMarkdownTag: "tr" },
ul: { paseoMarkdownTag: "ul" },
unwrap: { paseoMarkdownUnwrap: "true" },
ignore: { paseoMarkdownIgnore: "true" },
} as const;
export type MarkdownCopyInlineTag = "br" | "code" | "em" | "s" | "strong";
export function markdownCopyOrderedListDataSet(start: unknown) {
return {
...markdownCopyDataSet.ol,
paseoMarkdownListStart: String(start ?? 1),
} as const;
}
export function markdownCopyCodeBlockDataSet(language: string | null | undefined) {
const fenceLanguage = language?.trim().split(/\s+/)[0];
return {
@@ -65,25 +32,7 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
} as const;
}
/**
* An image copies as the Markdown it came from. The rendered `img` points at a preview
* URL for data and workspace images, and carries no alt text of its own.
*/
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
return {
paseoMarkdownTag: "img",
paseoMarkdownSrc: source,
...(alt ? { paseoMarkdownAlt: alt } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
? style.match(/(?:^|;)\s*text-align\s*:\s*(left|right|center)/i)?.[1]
: null;
return {
...markdownCopyDataSet[tag],
...(alignment ? { paseoMarkdownAlign: alignment.toLowerCase() } : {}),
} as const;
export const MARKDOWN_COPY_IMAGE_SOURCES_ATTRIBUTE = "data-paseo-markdown-image-sources";
export function markdownCopyImageSourcesDataSet(sources: Record<string, string>) {
return { paseoMarkdownImageSources: JSON.stringify(sources) };
}
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -25,3 +26,8 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
export interface AudioEngineOptions {
traceLabel?: string;
captureMode?: "conversation" | "dictation";
}
+18 -3
View File
@@ -1,4 +1,9 @@
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
options?: AudioEngineOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -47,6 +52,12 @@ export function createAudioEngine(
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data === "capture_paused" || event.data === "capture_resumed") {
if (refs.captureActive) {
callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
}
return;
}
if (event.data !== "blocked") {
return;
}
@@ -180,7 +191,10 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
const isRecording = native.toggleRecording(true);
const isRecording =
options?.captureMode === "dictation"
? native.startDictation()
: native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -201,6 +215,7 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
+7 -2
View File
@@ -1,6 +1,11 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
import type {
AudioEngine,
AudioEngineCallbacks,
AudioEngineOptions,
AudioPlaybackSource,
} from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
_options?: { traceLabel?: string },
_options?: AudioEngineOptions,
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
@@ -0,0 +1,11 @@
import type { CappedAssistantMessage } from "./assistant-message-render-limit.shared";
export {
getUtf8ByteLength,
type CappedAssistantMessage,
} from "./assistant-message-render-limit.shared";
// Native passes a single string to the C++ parser, without the web renderer's
// React-node fan-out. Keep complete messages selectable across all paragraphs.
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
return { text: message, capped: false };
}
@@ -0,0 +1,30 @@
export interface CappedAssistantMessage {
text: string;
capped: boolean;
}
export function getUtf8ByteLength(message: string): number {
let bytes = 0;
for (let index = 0; index < message.length; index += 1) {
const codeUnit = message.charCodeAt(index);
if (codeUnit < 0x80) {
bytes += 1;
} else if (codeUnit < 0x800) {
bytes += 2;
} else if (
codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
index + 1 < message.length &&
message.charCodeAt(index + 1) >= 0xdc00 &&
message.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
@@ -56,3 +56,11 @@ describe("assistant message render limit", () => {
});
});
});
it("preserves complete native multi-paragraph messages and the newline-heavy repro", async () => {
const native = await import("./assistant-message-render-limit.native");
const message = "Paragraph.\n\n".repeat(3000) + "Section 60";
expect(native.capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
const lines = "x\n".repeat(16_000) + "HIDDEN_OVERSIZED_MESSAGE_TAIL";
expect(native.capAssistantMessageForRender(lines)).toEqual({ text: lines, capped: false });
});
@@ -1,55 +1 @@
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
export interface CappedAssistantMessage {
text: string;
capped: boolean;
}
export function getUtf8ByteLength(message: string): number {
let bytes = 0;
for (let index = 0; index < message.length; index += 1) {
const codeUnit = message.charCodeAt(index);
if (codeUnit < 0x80) {
bytes += 1;
} else if (codeUnit < 0x800) {
bytes += 2;
} else if (
codeUnit >= 0xd800 &&
codeUnit <= 0xdbff &&
index + 1 < message.length &&
message.charCodeAt(index + 1) >= 0xdc00 &&
message.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
return { text: message, capped: false };
}
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
if (graphemeSegmenter) {
end = graphemeSegmenter.segment(message).containing(end)!.index;
} else {
const finalCodeUnit = message.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
end -= 1;
}
}
return { text: message.slice(0, end), capped: true };
}
export * from "./assistant-message-render-limit.web";
@@ -0,0 +1,30 @@
import type { CappedAssistantMessage } from "./assistant-message-render-limit.shared";
export {
getUtf8ByteLength,
type CappedAssistantMessage,
} from "./assistant-message-render-limit.shared";
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
return { text: message, capped: false };
}
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
if (graphemeSegmenter) {
end = graphemeSegmenter.segment(message).containing(end)!.index;
} else {
const finalCodeUnit = message.charCodeAt(end - 1);
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
end -= 1;
}
}
return { text: message.slice(0, end), capped: true };
}
@@ -87,7 +87,9 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<Text style={timerTextStyle}>
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
@@ -210,6 +212,11 @@ export function DictationOverlay({
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText
@@ -1,7 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import {
Pressable,
View,
Text,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownTextSpan } from "@/components/markdown-text";
import * as Clipboard from "expo-clipboard";
import { Check, Copy } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -96,13 +102,13 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
onPointerLeave={handlePointerLeave}
>
{keyedLines ? (
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
{renderCodeSegments(keyedLines)}
</MarkdownTextSpan>
</Text>
) : (
<MarkdownTextSpan style={innerTextStyle} copyTag="code">
<Text style={innerTextStyle} dataSet={markdownCopyDataSet.code}>
{renderedCode}
</MarkdownTextSpan>
</Text>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
@@ -129,9 +135,7 @@ interface TokenSpanProps {
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
return (
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</MarkdownTextSpan>
<Text style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>{token.text}</Text>
);
});
@@ -140,7 +144,7 @@ interface CodeTextSpanProps {
}
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
return <Text>{text}</Text>;
});
interface SplitStyles {
+11 -7
View File
@@ -32,6 +32,7 @@ import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
@@ -317,13 +318,16 @@ function FooterIconButton({
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/>
{({ hovered, pressed }) => (
<>
<SidebarFooterIconHighlight visible={pressed} />
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</Pressable>
</TooltipTrigger>
@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { iosMarkdownTextIsSelectable } from "./markdown-text-selection";
describe("markdown text selection", () => {
it("uses plain text only for iOS table cells", () => {
expect({
tableCell: iosMarkdownTextIsSelectable("table-cell"),
prose: iosMarkdownTextIsSelectable("prose"),
}).toEqual({
tableCell: false,
prose: true,
});
});
});
@@ -1,17 +0,0 @@
import { createContext, type ReactNode, useContext } from "react";
export type MarkdownTextSurface = "prose" | "table-cell";
const MarkdownTextSurfaceContext = createContext<MarkdownTextSurface>("prose");
export function MarkdownTableCellText({ children }: { children: ReactNode }) {
return <MarkdownTextSurfaceContext value="table-cell">{children}</MarkdownTextSurfaceContext>;
}
export function useMarkdownTextSurface(): MarkdownTextSurface {
return useContext(MarkdownTextSurfaceContext);
}
export function iosMarkdownTextIsSelectable(surface: MarkdownTextSurface): boolean {
return surface !== "table-cell";
}
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { StyleSheet, type TextStyle } from "react-native";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
function unistylesStyle(id: string, style: Record<string, unknown>) {
return {
...style,
[`unistyles_${id}`]: { id },
};
}
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
// before passing the result to its native View-backed components.
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
}
function unistylesMetadataKeys(style: Record<string, unknown>) {
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
}
describe("resolvePlainMarkdownTextStyle", () => {
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
const merged = uiTextViewFlatten(
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
);
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
});
});
@@ -1,25 +0,0 @@
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
}
function stripUnistylesMetadata(style: TextStyle): TextStyle {
// iOS markdown text goes through react-native-uitextview. That library
// inherits text styles by flattening [parentStyle, childStyle] before handing
// the result to native View-backed components. If both entries are Unistyles
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
// and Unistyles correctly warns that the style should have stayed array-shaped.
//
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
// our ownership model. Resolve the concrete style values before crossing that
// boundary and drop only Unistyles' private tracking metadata. This preserves
// iOS paragraph/spanning text selection while avoiding the metadata merge.
const plainStyle: Record<string, unknown> = { ...style };
for (const key of Object.keys(plainStyle)) {
if (key.startsWith("unistyles_")) {
delete plainStyle[key];
}
}
return plainStyle as TextStyle;
}
@@ -1,56 +0,0 @@
import { useMemo, type ReactNode } from "react";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// Android's <Text selectable> enables per-text-node selection natively. Each
// sibling Text is its own selection scope — drag can't span across siblings
// (that requires a single UITextView ancestor and is iOS-only). onPress works
// natively here, so links routed through this span stay tappable on Android.
export function MarkdownTextSpan({
style,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
return (
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</Text>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
// in ReactBaseTextShadowNode), so this isn't a crash-avoidance choice — but
// inline-placeholder spans collapse block-level children (e.g. paragraph
// images) into one-character placeholders, which destroys image row layout.
// <View> preserves the original block layout; the trade-off is no cross-span
// selection on Android (a UITextView-style trick has no Android equivalent).
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return <View style={style}>{children}</View>;
}
-1
View File
@@ -1 +0,0 @@
export * from "./markdown-text.ios";
@@ -1,113 +0,0 @@
import { useMemo, type ReactNode } from "react";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import { UITextView } from "react-native-uitextview";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
import {
iosMarkdownTextIsSelectable,
useMarkdownTextSurface,
} from "@/components/markdown-text-selection";
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
// Links route through this span too (see assistant-file-links/link.tsx). A
// plain <Text> nested in the paragraph UITextView is dropped, so the link
// must be a UITextView span to be visible. onPress is wired onto the leaf
// string children here: react-native-uitextview attaches it to the
// RNUITextViewChild nodes it builds from string content, which the native tap
// recognizer dispatches to. The link's handler reaches these leaf spans via
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// Inline span backed by UITextView so iOS gets native word-selection handles.
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
// library's TextAncestorContext hoists these into UITextViewChild nodes so
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
export function MarkdownTextSpan({
style,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
const surface = useMarkdownTextSurface();
// Each selectable span creates a UIKit UITextView with a window-level tap recognizer.
// A large table would create one per cell and make every app touch fan out across them.
if (!iosMarkdownTextIsSelectable(surface)) {
return (
<Text
selectable={false}
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</Text>
);
}
return (
<UITextView
uiTextView
selectable
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</UITextView>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
// native text view. That's what unlocks cross-inline drag selection — handles
// can span every MarkdownTextSpan child inside this paragraph.
// ViewStyle is structurally compatible with the layout props paragraphs use
// (margin, padding, alignment); the cast lets the existing paragraphStyle
// flow through unchanged.
export function MarkdownParagraphView({
paragraphStyle,
containsImage = false,
children,
}: MarkdownParagraphViewProps) {
const textStyle = useMemo(
() =>
resolvePlainMarkdownTextStyle([
paragraphStyle,
MARKDOWN_PARAGRAPH_RESET,
] as StyleProp<TextStyle>),
[paragraphStyle],
);
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
if (containsImage) {
return <View style={viewStyle}>{children}</View>;
}
return (
<UITextView uiTextView selectable style={textStyle}>
{children}
</UITextView>
);
}
@@ -1,73 +0,0 @@
import { useMemo, type ReactNode } from "react";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet, type MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
copyTag?: MarkdownCopyInlineTag;
children: ReactNode;
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
// are accepted for prop-shape parity with the native variants and forwarded
// harmlessly.
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// react-native-web renders Text as <span>/<div> with `user-select: text`
// already applied via markdownStyleMapping. The web bundle must not import
// react-native-uitextview: its transitive import of codegenNativeComponent
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
// target in dev mode.
export function MarkdownTextSpan({
style,
monoSurface,
copyTag,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
const dataSet = useMemo(() => {
if (copyTag && (monoSurface || copyTag === "code")) {
return { ...CODE_SURFACE_DATASET, ...markdownCopyDataSet[copyTag] };
}
if (copyTag) {
return markdownCopyDataSet[copyTag];
}
return monoSurface ? CODE_SURFACE_DATASET : undefined;
}, [copyTag, monoSurface]);
return (
<Text dataSet={dataSet} style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</Text>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
containsImage?: boolean;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
// Same shape as Android — paragraph is a View so block-level children (images)
// keep their natural layout. Web text selection already spans nested inline
// elements via CSS user-select, so no UITextView equivalent is needed.
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
return (
<View style={style} dataSet={markdownCopyDataSet.p}>
{children}
</View>
);
}
@@ -1,42 +0,0 @@
import type { ComponentType } from "react";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { getMarkdownFenceLanguage } from "./language";
import { MermaidFence } from "./mermaid";
import type { MarkdownFenceRendererProps } from "./types";
export interface MarkdownFenceBlockProps extends MarkdownFenceRendererProps {
info: string | null | undefined;
}
const diagramFences: Partial<Record<string, ComponentType<MarkdownFenceRendererProps>>> = {
mermaid: MermaidFence,
};
export function MarkdownFenceBlock({
code,
info,
phase,
inheritedStyles,
textStyle,
}: MarkdownFenceBlockProps) {
const language = getMarkdownFenceLanguage(info);
const DiagramFence = language ? diagramFences[language] : undefined;
if (DiagramFence) {
return (
<DiagramFence
code={code}
phase={phase}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
return (
<HighlightedCodeBlock
code={code}
language={language}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
@@ -1,15 +0,0 @@
import { describe, expect, it } from "vitest";
import { getMarkdownFenceLanguage } from "./language";
describe("getMarkdownFenceLanguage", () => {
it("normalizes the first info-string token", () => {
expect(getMarkdownFenceLanguage(" Sirena theme=dark ")).toBe("sirena");
expect(getMarkdownFenceLanguage("TypeScript title=example")).toBe("typescript");
});
it("returns null when no language is declared", () => {
expect(getMarkdownFenceLanguage("")).toBeNull();
expect(getMarkdownFenceLanguage(null)).toBeNull();
expect(getMarkdownFenceLanguage(undefined)).toBeNull();
});
});
@@ -1,3 +0,0 @@
export function getMarkdownFenceLanguage(info: string | null | undefined): string | null {
return info?.trim().split(/\s+/)[0]?.toLowerCase() || null;
}
@@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MarkdownFenceRendererProps } from "./types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import {
@@ -1,5 +1,5 @@
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { MarkdownFenceRendererProps } from "../types";
import type { MarkdownFenceRendererProps } from "./types";
export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
return (
@@ -6,7 +6,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MarkdownFenceRendererProps } from "./types";
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import { useMermaidRenderModel } from "./use-render-model";
@@ -1,6 +1,19 @@
import type { MarkdownFenceRendererProps } from "../types";
import { createContext, useContext } from "react";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { MermaidFenceHost } from "./host";
export function MermaidFence(props: MarkdownFenceRendererProps) {
return <MermaidFenceHost {...props} />;
export { splitMermaidFences } from "./segments";
export const MermaidStreamingContext = createContext(false);
const ThemedHost = withUnistyles(MermaidFenceHost);
const styles = (theme: Theme) => ({
inheritedStyles: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
export function MermaidFence({ code }: { code: string }) {
const streaming = useContext(MermaidStreamingContext);
return <ThemedHost code={code} phase={streaming ? "streaming" : "complete"} uniProps={styles} />;
}
@@ -1,4 +1,4 @@
import type { MarkdownPhase } from "../types";
import type { MarkdownPhase } from "./types";
export type DiagramColorScheme = "light" | "dark";
@@ -0,0 +1,57 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Definition, RootContent } from "mdast";
interface Segment {
kind: "markdown" | "mermaid";
id: string;
source: string;
}
/** Native enriched-markdown has no fenced-block override. Only diagrams leave its engine. */
export function splitMermaidFences(markdown: string): Segment[] {
if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", id: "0", source: markdown }];
const segments: Segment[] = [];
const definitions = new Map<string, Definition>();
let cursor = 0;
function visit(node: RootContent): void {
if (node.type === "definition" && !definitions.has(node.identifier))
definitions.set(node.identifier, node);
if (node.type === "code" && node.lang?.toLowerCase() === "mermaid" && node.position) {
const start = node.position.start.offset!;
const end = node.position.end.offset!;
if (start > cursor)
segments.push({
kind: "markdown",
id: String(segments.length),
source: markdown.slice(cursor, start),
});
segments.push({ kind: "mermaid", id: String(segments.length), source: node.value });
cursor = end;
} else if ("children" in node) node.children.forEach(visit);
}
const tree = fromMarkdown(markdown);
tree.children.forEach(visit);
if (cursor < markdown.length || segments.length === 0)
segments.push({
kind: "markdown",
id: String(segments.length),
source: markdown.slice(cursor),
});
// Prepend the document's first definitions after splitting, so fragment-local
// duplicates cannot override them. Original source slices and fence offsets stay intact.
if (segments.some((segment) => segment.kind === "mermaid")) {
const referenceSource = [...definitions.values()]
.map((node) => {
const label = node.identifier.replace(/[\\[\]]/g, "\\$&");
const url = node.url.replace(/[\\<>]/g, "\\$&");
const title = node.title ? ` "${node.title.replace(/[\\"]/g, "\\$&")}"` : "";
return `[${label}]: <${url}>${title}`;
})
.join("\n");
if (referenceSource)
for (const segment of segments) {
if (segment.kind === "markdown") segment.source = `${referenceSource}\n\n${segment.source}`;
}
}
return segments;
}
@@ -1,7 +1,5 @@
import type { TextStyle } from "react-native";
export type MarkdownPhase = "streaming" | "complete";
export interface MarkdownFenceRendererProps {
code: string;
phase: MarkdownPhase;
@@ -8,7 +8,7 @@ import {
type RenderedDiagram,
} from "./render-model";
import { containsUnsafeMermaidSource } from "./source-policy";
import type { MarkdownPhase } from "../types";
import type { MarkdownPhase } from "./types";
const renderCache = new Map<string, RenderedDiagram>();
const RENDER_CACHE_LIMIT = 50;
@@ -0,0 +1,18 @@
import React, { type ReactElement } from "react";
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { isWeb } from "@/constants/platform";
export function MarkdownRendererFrame({
imageSources,
children,
}: {
imageSources: Record<string, string>;
children: ReactElement;
}) {
return (
<View dataSet={isWeb ? markdownCopyImageSourcesDataSet(imageSources) : undefined}>
{children}
</View>
);
}
@@ -1,529 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
"",
"```ts",
'const isBrowser = userAgent.includes("Mozilla");',
"```",
].join("\n");
const multiDetailsBody = [
"### Bot Review",
"",
"<details>",
"<summary><h3>Important Files Changed</h3></summary>",
"",
"- `packages/server/src/server/session.ts`",
"",
"</details>",
"",
"<details>",
"<summary><h3>Security Findings</h3></summary>",
"",
"No blocking findings.",
"",
"</details>",
"",
"<!-- bot_other_comments_section -->",
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
].join("\n");
it("classifies linked HTML images as generic inline images, not block markdown images", () => {
const [image, text] = splitHtmlishMarkdown(inlineImageBody);
expect(image).toEqual({
kind: "inlineImage",
alt: "Priority",
src: "https://example.com/assets/priority.svg?v=9",
flowsWithText: true,
});
expect(text).toEqual({
kind: "markdown",
text: [
" Spoofed browser User-Agent allows access control bypass",
"",
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
"",
"```ts",
'const isBrowser = userAgent.includes("Mozilla");',
"```",
].join("\n"),
});
});
it("flags both images as flowsWithText when two inline images precede title text on the same line", () => {
const twoImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **Title text here**',
"",
"Body paragraph.",
].join("\n");
const parts = splitHtmlishMarkdown(twoImageBody);
const [first, second, third] = parts;
expect(first).toEqual({
kind: "inlineImage",
alt: "Priority",
src: "https://example.com/priority.svg",
flowsWithText: true,
});
expect(second).toEqual({ kind: "markdown", text: " " });
expect(third).toEqual({
kind: "inlineImage",
alt: "Security",
src: "https://example.com/security.svg",
flowsWithText: true,
});
});
it("keeps safe width and height attributes on inline images", () => {
expect(
splitHtmlishMarkdown(
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
),
).toEqual([
{
kind: "inlineImage",
alt: "Small",
src: "https://example.com/small.svg",
width: 18,
height: 12,
},
]);
});
it("keeps safe non-empty image links", () => {
expect(
splitHtmlishMarkdown(
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
),
).toEqual([
{
kind: "inlineImage",
alt: "Small",
src: "https://example.com/small.svg",
href: "https://example.com/details",
},
]);
});
it("renders a linked picture inside a heading without exposing its HTML", () => {
const source = [
'<h2><a href="https://example.com/retrigger?id=73851569"><picture>',
'<source media="(prefers-color-scheme: dark)" srcset="https://example.com/dark.svg">',
'<source media="(prefers-color-scheme: light)" srcset="https://example.com/light.svg">',
'<img alt="Retrigger" src="https://example.com/default.svg" width="85" height="24">',
"</picture></a></h2>",
"",
"Confidence Score: 5/5",
"",
"<details><summary>Summary</summary>No outstanding findings.</details>",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/default.svg",
href: "https://example.com/retrigger?id=73851569",
width: 85,
height: 24,
},
{ kind: "markdown", text: "\n\nConfidence Score: 5/5\n\n" },
{ kind: "details", summary: "Summary", body: "No outstanding findings." },
]);
});
it.each([
["", ""],
["<h3>", "</h3>"],
['<a href="javascript:alert(1)">', "</a>"],
])("uses the picture fallback without activating unsafe links (%s)", (wrapper, closing) => {
const picture =
'<picture><source srcset="https://example.com/alternate.svg"/><img src="https://example.com/default.svg" alt="Image"></picture>';
expect(splitHtmlishMarkdown(`${wrapper}${picture}${closing}`)).toEqual([
{ kind: "inlineImage", src: "https://example.com/default.svg", alt: "Image" },
]);
});
it("keeps picture examples literal inside inline and fenced code", () => {
const picture =
'<picture><source srcset="https://example.com/alternate.svg"><img src="https://example.com/default.svg"></picture>';
const source = `Use \`${picture}\`\n\n\`\`\`html\n${picture}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["p", "div"])("renders a linked picture inside %s", (wrapper) => {
const source = `<${wrapper}><a href="https://example.com/page"><picture><source srcset="https://example.com/dark.svg"><img src="https://example.com/default.svg" alt="Image"></picture></a></${wrapper}>`;
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
src: "https://example.com/default.svg",
alt: "Image",
href: "https://example.com/page",
},
]);
});
it.each(["h2", "p", "div"])("keeps text after an image-only %s on a separate row", (wrapper) => {
const source = `<${wrapper}><img src="https://example.com/image.svg" alt="Image"></${wrapper}>Next section`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "inlineImage", src: "https://example.com/image.svg", alt: "Image" },
{ kind: "markdown", text: "Next section" },
]);
});
it("handles deeply nested image wrappers without exhausting the stack", () => {
const source = `${"<div>".repeat(10000)}<a href="https://example.com/page"><picture><img src="https://example.com/image.svg" alt="Image"></picture></a>${"</div>".repeat(10000)}`;
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
src: "https://example.com/image.svg",
alt: "Image",
href: "https://example.com/page",
},
]);
});
it("preserves inline image parts inside details bodies", () => {
expect(
splitHtmlishMarkdown(
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
),
).toEqual([
{
kind: "details",
summary: "Images",
body: "Inline text",
bodyParts: [
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
href: "https://example.com/page",
flowsWithText: true,
},
{ kind: "markdown", text: " Inline text" },
],
},
]);
});
it("does not flag standalone inline images as flowing with text", () => {
expect(
splitHtmlishMarkdown('<img alt="Shot" src="https://example.com/shot.png">\n\nCaption below'),
).toEqual([
{ kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" },
{ kind: "markdown", text: "\n\nCaption below" },
]);
});
it("does not flag mid-line inline images as flowing with text", () => {
const [, image] = splitHtmlishMarkdown(
'Before <img alt="Icon" src="https://example.com/icon.png"> after',
);
expect(image).toEqual({
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.png",
});
});
it("leaves ordinary markdown images on the markdown path", () => {
const source = "![Ordinary](https://example.com/full-size.png)";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("leaves unsafe HTML image sources inert", () => {
const source = '<img alt="Bad" src="javascript:alert(1)">';
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("removes raw image anchor and image tags from rendered markdown text", () => {
const text = splitHtmlishMarkdown(inlineImageBody)
.map((part) => (part.kind === "markdown" ? part.text : ""))
.join("");
expect(text).not.toContain("<a ");
expect(text).not.toContain("<img ");
expect(text).not.toContain("</a>");
});
it("unwraps sub text and strips HTML comments", () => {
const parts = splitHtmlishMarkdown(multiDetailsBody);
const tail = parts.at(-1);
expect(tail).toEqual({
kind: "markdown",
text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)",
});
});
it("does not leak stray closing details tags across multiple details blocks", () => {
const renderedText = splitHtmlishMarkdown(multiDetailsBody)
.map((part) => {
if (part.kind === "markdown") return part.text;
if (part.kind === "details") return `${part.summary}\n${part.body}`;
return part.alt;
})
.join("\n");
expect(renderedText).not.toContain("</details>");
expect(renderedText).not.toContain("<!--");
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
{ kind: "markdown", text: "### Bot Review\n\n" },
{ kind: "details", summary: "Important Files Changed" },
{ kind: "markdown", text: "\n\n" },
{ kind: "details", summary: "Security Findings" },
]);
});
it("preserves paragraph boundaries around title, prose, and code block", () => {
const text = splitHtmlishMarkdown(inlineImageBody)
.map((part) => (part.kind === "markdown" ? part.text : ""))
.join("");
expect(text).toContain(
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
);
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
});
it("keeps product-specific image language out of parser and renderer logic", () => {
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
const productionSource = `${parserSource}\n${rendererSource}`;
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
});
it("parses Greptile-style details blocks", () => {
expect(
splitHtmlishMarkdown(
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
),
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
});
it("keeps details inside fenced code as literal markdown", () => {
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("keeps details inside inline code on native runtimes without copied array sorting", () => {
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
expect(withoutArrayToSorted(() => splitHtmlishMarkdown(source))).toEqual([
{ kind: "markdown", text: source },
]);
});
it("keeps details inside inline code as literal markdown", () => {
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("terminates when an unmatched backtick follows a closed inline code span", () => {
const source = "Use `<details><summary>Example</summary>Body</details>` before `dangling";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("still parses normal details outside code", () => {
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
{ kind: "markdown", text: "`code`\n" },
{ kind: "details", summary: "Real", body: "Body" },
]);
});
it("preserves text before and after details blocks", () => {
expect(
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
).toEqual([
{ kind: "markdown", text: "Before\n" },
{ kind: "details", summary: "More", body: "Hidden" },
{ kind: "markdown", text: "\nAfter" },
]);
});
it("parses multiple details blocks", () => {
expect(
splitHtmlishMarkdown(
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
),
).toEqual([
{ kind: "details", summary: "One", body: "A" },
{ kind: "details", summary: "Two", body: "B" },
]);
});
it("normalizes br and simple code tags into markdown", () => {
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
"Line 1\nLine 2 `safe-value`",
);
});
it("normalizes HTML table cells and inline formatting into markdown", () => {
expect(
normalizeHtmlishMarkdown(
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
),
).toBe(
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
);
});
it("leaves complex code tags inert instead of parsing HTML", () => {
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
'<code onclick="evil()"><script>x</script></code>',
);
});
it("keeps table rows after a comparison that only looks like a tag", () => {
const source = [
"| ID | Note | Status |",
"|---|---|---|",
"| a-1 | accepts 1<size<2 only | open |",
"| a-2 | second row | open |",
"",
"Later x -> y.",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
"keeps %j literal because it is not HTML",
(source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
},
);
it.each([
"Call As<Integer>() before limits<DWORD>::max()",
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
])("echoes unknown tags in %j as written", (source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
});
it("rebuilds an unknown tag that a code span splits", () => {
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
});
it("still reads tags and comments next to literal angle brackets", () => {
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
"1<n<2 **bold** 3<4",
);
});
it("falls back to inert markdown when details are unclosed", () => {
const source = "<details><summary>Open</summary>Still open";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("falls back to inert markdown when summary is missing", () => {
const source = "<details>Hidden</details>";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("does not execute or render unknown HTML as structured content", () => {
const source =
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "<script>alert(1)</script>" },
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
]);
});
});
function withoutArrayToSorted<T>(callback: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted");
Reflect.deleteProperty(Array.prototype, "toSorted");
try {
return callback();
} finally {
if (descriptor) {
Reflect.defineProperty(Array.prototype, "toSorted", descriptor);
}
}
}
@@ -1,926 +0,0 @@
import { Parser } from "htmlparser2";
export interface MarkdownTextPart {
kind: "markdown";
text: string;
}
export interface MarkdownDetailsPart {
kind: "details";
summary: string;
body: string;
bodyParts?: MarkdownDisplayPart[];
}
export interface MarkdownInlineImagePart {
kind: "inlineImage";
alt: string;
src: string;
href?: string;
width?: number;
height?: number;
/** The image starts a line and text continues on that same line. */
flowsWithText?: boolean;
}
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const HTML_TAG_RE = new RegExp(
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
"y",
);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
em: ["*", "*"],
i: ["*", "*"],
s: ["~~", "~~"],
strike: ["~~", "~~"],
strong: ["**", "**"],
};
interface ProtectedMarkdownRange {
start: number;
end: number;
}
interface MarkdownImageDimensions {
width?: number;
height?: number;
}
interface HtmlTextToken {
kind: "text";
value: string;
}
interface HtmlCommentToken {
kind: "comment";
}
interface HtmlTagToken {
kind: "tag";
name: string;
closing: boolean;
selfClosing: boolean;
attributes: Record<string, string>;
raw: string;
}
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
interface InlineImageParseResult {
part: MarkdownInlineImagePart;
end: number;
}
interface MarkdownDelimiterMatch {
index: number;
end: number;
}
interface HtmlishTokenParser {
write(chunk: string): void;
skip(length: number): void;
end(): void;
}
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
}
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
const parts: MarkdownDisplayPart[] = [];
let cursor = 0;
while (cursor < tokens.length) {
const token = tokens[cursor];
if (isOpenTag(token, "details")) {
const closeIndex = findMatchingClose(tokens, cursor, "details");
if (closeIndex !== null) {
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
if (details) {
parts.push(details);
cursor = closeIndex + 1;
continue;
}
}
}
const inlineImage = parseInlineImageAt(tokens, cursor);
if (inlineImage) {
parts.push(
flowsWithFollowingText(tokens, cursor, inlineImage.end)
? { ...inlineImage.part, flowsWithText: true }
: inlineImage.part,
);
cursor = inlineImage.end;
continue;
}
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
return parts;
}
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
const token = tokens[start];
if (token?.kind !== "tag" || token.closing) {
return null;
}
if (token.name === "img") {
const image = imageTokenToInlineImage(token, undefined);
return image ? { part: image, end: start + 1 } : null;
}
if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) {
return null;
}
return parseWrappedImageAt(tokens, start);
}
function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
// Walk an image-only wrapper chain once. Recursing over copied child slices
// makes deeply nested review HTML quadratic and can exhaust the JS stack.
const wrappers: string[] = [];
let image: MarkdownInlineImagePart | null = null;
let href: string | undefined;
let hasLink = false;
for (let index = start; index < tokens.length; index += 1) {
const child = tokens[index];
if (child.kind === "comment" || isWhitespaceText(child)) {
continue;
}
if (child.kind !== "tag") {
return null;
}
if (child.closing) {
if (!image || wrappers.pop() !== child.name) {
return null;
}
if (wrappers.length === 0) {
return { part: image, end: index + 1 };
}
continue;
}
// Pictures use their img fallback, not responsive source selection.
if (child.name === "source" && wrappers.at(-1) === "picture") {
continue;
}
if (image) {
return null;
}
if (child.name === "img") {
image = imageTokenToInlineImage(child, href);
if (!image) {
return null;
}
continue;
}
if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) {
return null;
}
if (child.name === "a" && !hasLink) {
href = safeHref(child.attributes.href);
hasLink = true;
}
wrappers.push(child.name);
}
return null;
}
function isImageBlockWrapper(token: HtmlToken | undefined): boolean {
return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div");
}
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
if (isImageBlockWrapper(tokens[start])) {
return false;
}
const previous = tokens[start - 1];
// At line start if nothing precedes this image, or the preceding token ends with only
// whitespace since the last newline (covers a bare space between two images on the same line).
const atLineStart =
previous === undefined ||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
if (!atLineStart) {
return false;
}
// Scan forward past same-line whitespace-only text tokens and inline images to find
// the first substantive text token, without crossing a newline.
let cursor = end;
while (cursor < tokens.length) {
const token = tokens[cursor];
if (token === undefined) {
break;
}
if (token.kind === "text") {
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
if (sameLine.trim().length > 0) {
return true;
}
// Whitespace-only on this line — keep scanning only if no newline was crossed.
if (token.value.includes("\n") || token.value.includes("\r")) {
return false;
}
cursor += 1;
continue;
}
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
if (token.kind === "tag") {
if (isImageBlockWrapper(token)) {
return false;
}
const imageResult = parseInlineImageAt(tokens, cursor);
if (imageResult) {
cursor = imageResult.end;
continue;
}
}
break;
}
return false;
}
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
}
const previous = parts.at(-1);
if (previous?.kind === "markdown") {
previous.text += text;
return;
}
parts.push({ kind: "markdown", text });
}
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
if (summaryOpenIndex === null) {
return null;
}
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
if (summaryCloseIndex === null) {
return null;
}
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
const summary = renderSummaryTokens(summaryTokens).trim();
if (!summary) {
return null;
}
const bodyParts = splitHtmlishTokens(bodyTokens);
const body = renderBodyText(bodyParts);
return {
kind: "details",
summary,
body: body.trim(),
...(bodyParts.some((part) => part.kind !== "markdown")
? { bodyParts: trimBodyParts(bodyParts) }
: {}),
};
}
function renderBodyText(parts: MarkdownDisplayPart[]): string {
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
}
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
const trimmed = [...parts];
const first = trimmed[0];
if (first?.kind === "markdown") {
first.text = first.text.trimStart();
}
const last = trimmed.at(-1);
if (last?.kind === "markdown") {
last.text = last.text.trimEnd();
}
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
}
function renderSummaryTokens(tokens: HtmlToken[]): string {
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
}
function stripSingleHeadingWrapper(text: string): string {
const tokens = tokenizeHtmlishMarkdown(text.trim());
if (tokens.length < 3) {
return text;
}
const first = tokens[0];
const last = tokens.at(-1);
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
return text;
}
return renderInlineTokens(tokens.slice(1, -1));
}
function renderInlineTokens(tokens: HtmlToken[]): string {
let output = "";
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (token.kind === "text") {
output += token.value;
continue;
}
if (token.kind === "comment" || token.closing) {
continue;
}
if (token.name === "br") {
output += "\n";
continue;
}
if (token.name === "img") {
output += renderImageToken(token);
continue;
}
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
output += renderUnknownTag(token);
continue;
}
const children = tokens.slice(index + 1, closeIndex);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
if (token.name === "sub" || isHeadingTagName(token.name)) {
return renderInlineTokens(children);
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
return `\`${renderInlineTokens(children)}\``;
}
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
if (wrapper) {
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
}
if (token.name === "table") {
return renderTableTokens(children);
}
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
const rows: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
if (!isOpenTag(tokens[index], "tr")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, "tr");
if (closeIndex === null) {
continue;
}
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
if (cells.length === 1) {
rows.push(`- ${cells[0]}`);
} else if (cells.length > 1) {
const label =
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
}
index = closeIndex;
}
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
}
function renderTableCells(tokens: HtmlToken[]): string[] {
const cells: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
continue;
}
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
if (cell) {
cells.push(cell);
}
index = closeIndex;
}
return cells;
}
function renderImageToken(token: HtmlTagToken): string {
const image = imageTokenToInlineImage(token, undefined);
if (!image) {
return token.raw;
}
return `![${escapeMarkdownImageAlt(image.alt)}](${image.src})`;
}
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
const imageOnly = getSingleImageChild(children);
if (imageOnly) {
return renderImageToken(imageOnly);
}
const label = renderInlineTokens(children);
const href = token.attributes.href ?? "";
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
return label;
}
return `[${label}](${href})`;
}
function imageTokenToInlineImage(
token: HtmlTagToken,
href: string | undefined,
): MarkdownInlineImagePart | null {
const src = token.attributes.src ?? "";
if (!SAFE_IMAGE_SRC_RE.test(src)) {
return null;
}
return {
kind: "inlineImage",
alt: token.attributes.alt ?? "",
src,
...(href ? { href } : {}),
...parseImageDimensions(token.attributes),
};
}
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
return {
...parseImageDimension("width", attributes.width),
...parseImageDimension("height", attributes.height),
};
}
function parseImageDimension(key: "width" | "height", value: string | undefined) {
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
return {};
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
}
function safeHref(href: string | undefined): string | undefined {
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
return undefined;
}
return href;
}
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
}
function renderUnknownTag(token: HtmlTagToken): string {
if (token.name === "script" || token.name === "style") {
return "";
}
return token.raw;
}
function escapeMarkdownImageAlt(value: string): string {
return value.replace(/]/g, "\\]");
}
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (isOpenTag(tokens[index], name)) {
return index;
}
}
return null;
}
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
let depth = 1;
for (let index = openIndex + 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (isOpenTag(token, name) && !token.selfClosing) {
depth += 1;
continue;
}
if (isClosingTag(token, name)) {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
return null;
}
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
return token?.kind === "tag" && token.name === name && !token.closing;
}
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
return token?.kind === "tag" && token.name === name && token.closing;
}
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
}
function isHeadingTagName(name: string): boolean {
return (
name === "h1" ||
name === "h2" ||
name === "h3" ||
name === "h4" ||
name === "h5" ||
name === "h6"
);
}
function isWhitespaceText(token: HtmlToken): boolean {
return token.kind === "text" && token.value.trim() === "";
}
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
const protectedRanges = getProtectedMarkdownRanges(source);
const tokens: HtmlToken[] = [];
const parser = createHtmlishTokenParser(source, tokens);
let cursor = 0;
for (const range of protectedRanges) {
parser.write(source.slice(cursor, range.start));
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
parser.skip(range.end - range.start);
cursor = range.end;
}
parser.write(source.slice(cursor));
parser.end();
return tokens;
}
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
let writtenLength = 0;
let chunkStart = 0;
let parser!: Parser;
// Unknown tags render as text, so keep them as written: the parser lowercases names
// and rewrites attributes. A tag split by a protected range has no single slice.
const readRawTag = (): string | null =>
parser.startIndex < chunkStart
? null
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
if (isImplied) {
return;
}
const selfClosing = VOID_HTML_TAGS.has(name);
tokens.push({
kind: "tag",
name,
closing: false,
selfClosing,
attributes,
raw: readRawTag() ?? renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
if (isImplied || VOID_HTML_TAGS.has(name)) {
return;
}
tokens.push({
kind: "tag",
name,
closing: true,
selfClosing: false,
attributes: {},
raw: readRawTag() ?? `</${name}>`,
});
},
ontext(value) {
if (stripNextLeadingLineBreak) {
stripNextLeadingLineBreak = false;
value = stripLeadingLineBreak(value);
}
if (!value) {
return;
}
tokens.push({ kind: "text", value });
},
oncomment() {
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
tokens.push({ kind: "comment" });
},
},
{
decodeEntities: false,
lowerCaseAttributeNames: true,
lowerCaseTags: true,
recognizeSelfClosing: true,
},
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
skippedLength += length;
},
end() {
parser.end();
},
};
}
function isLineStart(source: string, index: number): boolean {
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
}
function stripLeadingLineBreak(value: string): string {
if (value.startsWith("\r\n")) {
return value.slice(2);
}
if (value.startsWith("\n") || value.startsWith("\r")) {
return value.slice(1);
}
return value;
}
function renderStartTag(name: string, attributes: Record<string, string>): string {
const renderedAttributes = Object.entries(attributes)
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
.join("");
return `<${name}${renderedAttributes}>`;
}
function escapeAttribute(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
const codeRanges = mergeProtectedRanges([
...fencedRanges,
...getInlineCodeRanges(source, fencedRanges),
]);
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
}
/**
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
*/
function getLiteralAngleBracketRanges(
source: string,
codeRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
let codeIndex = 0;
// Reused across comment openers so unclosed comments stay linear.
let commentClose = -1;
let index = source.indexOf("<");
while (index !== -1) {
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
codeIndex += 1;
}
const code = codeRanges[codeIndex];
if (code && code.start <= index) {
index = source.indexOf("<", code.end);
continue;
}
let htmlEnd: number | null;
if (source.startsWith("<!--", index)) {
if (commentClose !== source.length && commentClose < index + 2) {
const close = source.indexOf("-->", index + 2);
commentClose = close === -1 ? source.length : close;
}
htmlEnd = commentClose === source.length ? null : commentClose + 3;
} else {
HTML_TAG_RE.lastIndex = index;
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
}
if (htmlEnd === null) {
ranges.push({ start: index, end: index + 1 });
htmlEnd = index + 1;
}
index = source.indexOf("<", htmlEnd);
}
return ranges;
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
FENCE_LINE_RE.lastIndex = 0;
while (true) {
const open = FENCE_LINE_RE.exec(source);
if (!open) {
return ranges;
}
const marker = open[1];
if (!marker) {
continue;
}
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
if (!close) {
ranges.push({ start: open.index, end: source.length });
return ranges;
}
ranges.push({ start: open.index, end: close.end });
FENCE_LINE_RE.lastIndex = close.end;
}
}
function findClosingFence(
source: string,
start: number,
marker: string,
): MarkdownDelimiterMatch | null {
const closeRe = new RegExp(
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
"gm",
);
closeRe.lastIndex = start;
const close = closeRe.exec(source);
return close ? { index: close.index, end: closeRe.lastIndex } : null;
}
function getInlineCodeRanges(
source: string,
fencedRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
BACKTICK_RUN_RE.lastIndex = 0;
while (true) {
const open = BACKTICK_RUN_RE.exec(source);
if (!open) {
return ranges;
}
if (isProtectedIndex(open.index, fencedRanges)) {
continue;
}
const marker = open[0];
const afterOpen = BACKTICK_RUN_RE.lastIndex;
const close = findClosingBacktickRun(source, afterOpen, marker, fencedRanges);
if (!close) {
// Unmatched backtick run — skip past it so the loop doesn't restart from 0.
// findClosingBacktickRun exhausts the global regex, which resets lastIndex
// to 0 when exec() returns null (ECMAScript §22.2.7.2).
BACKTICK_RUN_RE.lastIndex = afterOpen;
continue;
}
ranges.push({ start: open.index, end: close.end });
BACKTICK_RUN_RE.lastIndex = close.end;
}
}
function findClosingBacktickRun(
source: string,
start: number,
marker: string,
fencedRanges: ProtectedMarkdownRange[],
): MarkdownDelimiterMatch | null {
BACKTICK_RUN_RE.lastIndex = start;
while (true) {
const close = BACKTICK_RUN_RE.exec(source);
if (!close) {
return null;
}
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
}
}
}
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const merged: ProtectedMarkdownRange[] = [];
for (const range of sorted) {
const previous = merged.at(-1);
if (!previous || range.start > previous.end) {
merged.push({ ...range });
continue;
}
previous.end = Math.max(previous.end, range.end);
}
return merged;
}
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
return ranges.some((range) => index >= range.start && index < range.end);
}
export function normalizeHtmlishMarkdown(source: string): string {
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
}
@@ -0,0 +1,179 @@
import { Parser } from "htmlparser2";
import { fromMarkdown } from "mdast-util-from-markdown";
import { decodeString } from "micromark-util-decode-string";
import type { Nodes } from "mdast";
const ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const TAG = String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`;
const HTML = new RegExp(
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|<summary\b[^>]*>[\s\S]*?<\/summary\s*>|${TAG})`,
"gi",
);
const BLOCK_TAGS = new Set([
"div",
"p",
"table",
"tr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"section",
"article",
"ul",
"ol",
"li",
"blockquote",
"pre",
]);
const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "*", em: "*" };
/** Convert comment HTML to Markdown while preserving the original spelling of code. */
export function normalizeMarkdownHtml(source: string): string {
if (!source.includes("<")) return source;
const htmlRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
}
findHtml(fromMarkdown(source));
const codeRanges: { start: number; end: number }[] = [];
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
const tree = fromMarkdown(source, {
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
});
function protectCode(node: Nodes) {
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
} else if ("children" in node) node.children.forEach(protectCode);
}
protectCode(tree);
const links: (string | undefined)[] = [];
let previousTag: { name: string; end: number } | null = null;
const convert = (raw: string, offset: number): string => {
if (codeRanges.some(({ start, end }) => offset >= start && offset < end)) return raw;
if (raw.startsWith("<!--")) return "";
const summary = /^<summary\b[^>]*>([\s\S]*?)<\/summary\s*>$/i.exec(raw);
if (summary) {
const title = normalizeMarkdownHtml(summary[1]).trim();
// A summary is one bold paragraph, even when its HTML title uses headings
// or several block wrappers. Slice AST blocks so inline code stays literal.
const inline = fromMarkdown(title)
.children.map(({ position }) => title.slice(position!.start.offset!, position!.end.offset!))
.join(" ");
return `**${inline}**\n\n`;
}
const code = /^<code\b[^>]*>([\s\S]*?)<\/code\s*>$/i.exec(raw);
if (code)
return inlineCode(
code[1].replace(/&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
decodeString(entity),
),
);
const closing = raw.startsWith("</");
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
// Each gap is inspected once, rather than searching the complete prefix
// for a preceding tag on every table row or nested block.
const startsAfterBoundary = previousTag
? BLOCK_TAGS.has(previousTag.name) && /^\s*$/.test(source.slice(previousTag.end, offset))
: /^\s*$/.test(source.slice(0, offset));
previousTag = { name, end: offset + raw.length };
if (EMPHASIS[name]) return EMPHASIS[name];
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
if (boundary !== null) return boundary;
if (name === "a") return markdownAnchor(raw, closing, links);
if (name === "img" && !closing) {
const { src, alt = "" } = attributes(raw);
return src ? `![${alt.replace(/[[\]\\]/g, "\\$&")}](<${markdownUrl(src)}>)` : alt;
}
return "";
};
let result = "";
let cursor = 0;
for (const range of htmlRanges) {
if (range.start < cursor) continue;
result += source.slice(cursor, range.start);
// Inline HTML code has separate opening/closing AST nodes. Convert the pair
// together so its contents keep backslashes and get a safe backtick delimiter.
const paired = /^<(code|summary)\b[^>]*>[\s\S]*?<\/\1\s*>/i.exec(source.slice(range.start));
const end = paired ? Math.max(range.end, range.start + paired[0].length) : range.end;
result += source
.slice(range.start, end)
.replace(HTML, (raw, offset) => convert(raw, range.start + offset));
cursor = end;
}
return result + source.slice(cursor);
}
function attributes(tag: string): Record<string, string> {
let result: Record<string, string> = {};
const parser = new Parser({
onopentag: (_name, value) => {
result = value;
},
});
parser.end(tag);
return result;
}
function markdownUrl(url: string): string {
return url.replace(/[<>\r\n]/g, (character) => encodeURIComponent(character));
}
function inlineCode(content: string): string {
const runs = content.match(/`+/g) ?? [];
const marker = "`".repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
return `${marker}${padding}${content}${padding}${marker}`;
}
function markdownAnchor(raw: string, closing: boolean, links: (string | undefined)[]): string {
if (closing) {
const href = links.pop();
return href === undefined ? "" : `](<${markdownUrl(href)}>)`;
}
const { href } = attributes(raw);
links.push(href);
return href === undefined ? "" : "[";
}
function blockBoundary(
source: string,
raw: string,
offset: number,
closing: boolean,
startsAfterBoundary: boolean,
): string {
if (closing) {
const following = source.slice(offset + raw.length);
if (/^\s*$/.test(following) || following.startsWith("\n\n")) return "";
const nextClosingTag = /^\s*<\/([\w-]+)/.exec(following);
if (nextClosingTag && BLOCK_TAGS.has(nextClosingTag[1].toLowerCase())) return "";
} else if (startsAfterBoundary) {
return "";
}
return "\n\n";
}
function layoutBoundary(
name: string,
source: string,
raw: string,
offset: number,
closing: boolean,
startsAfterBoundary: boolean,
): string | null {
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
if (BLOCK_TAGS.has(name)) return blockBoundary(source, raw, offset, closing, startsAfterBoundary);
if ((name === "td" || name === "th") && closing)
return /^<t[dh]\b/i.test(source.slice(offset + raw.length)) ? " " : "";
return null;
}
@@ -0,0 +1,34 @@
type ImageDecoder = (
uri: string,
success: (width: number, height: number) => void,
failure: (error: unknown) => void,
) => void;
export function decodeMarkdownImage(uri: string, decode: ImageDecoder): Promise<true> {
return new Promise((resolve, reject) => {
decode(
uri,
(width, height) => {
if (width > 0 && height > 0) resolve(true);
else reject(new Error("Image has no decodable dimensions"));
},
reject,
);
});
}
interface MarkdownImageLoadState {
canAcquire: boolean;
url: string | undefined;
acquisitionFailed: boolean;
decodeFailed: boolean;
decoded: boolean;
}
export function getMarkdownImageStatus(
state: MarkdownImageLoadState,
): "pending" | "ready" | "unavailable" {
const cannotResolve = !state.canAcquire && state.url === undefined;
if (cannotResolve || state.acquisitionFailed || state.decodeFailed) return "unavailable";
return state.decoded ? "ready" : "pending";
}
@@ -1,4 +1,9 @@
import { describe, expect, it } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { prepareMarkdownSource } from "./source";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
@@ -163,3 +168,52 @@ describe("resolveMarkdownPreviewImageSource", () => {
).toBe("C:/Users/test/repo/images/hero.png");
});
});
describe("local assistant Markdown images", () => {
let workspaceRoot: string;
const imageBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
beforeEach(async () => {
workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-"));
await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true });
await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes);
await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes);
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true });
});
it.each([
"absolute angle",
"absolute encoded",
"relative angle",
"relative encoded",
"file URI",
"literal percent",
])("reads the actual image for the %s destination", async (form) => {
const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png");
const destinations: Record<string, string> = {
"absolute angle": `<${absolute}>`,
"absolute encoded": encodeURI(absolute),
"relative angle": "<My Apps/Мои/test.png>",
"relative encoded": encodeURI("My Apps/Мои/test.png"),
"file URI": `file://${encodeURI(absolute)}`,
"literal percent": `${workspaceRoot}/literal%2520name.png`,
};
const prepared = prepareMarkdownSource(`![test](${destinations[form]})`);
expect(prepared.images).toHaveLength(1);
const source = resolveMarkdownPreviewImageSource({
source: prepared.images[0]!.source,
markdownPath: "",
workspaceRoot,
})!;
const target = resolveAssistantImageSource({ source, workspaceRoot });
expect(target?.kind).toBe("file_rpc");
if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target");
await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes);
});
});
@@ -0,0 +1,133 @@
import { Image } from "react-native";
import { isWeb } from "@/constants/platform";
import { useMemo, useEffect, useId } from "react";
import { useFetchQueries } from "@/data/query";
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
import {
persistAttachmentFromBytes,
resolveAttachmentPreviewUrl,
releaseAttachmentPreviewUrl,
} from "@/attachments/service";
import { createAssistantImageAcquisitionCache } from "@/assistant-image/acquisition-cache";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { resolveMarkdownPreviewImageSource } from "./image-source";
import { prepareMarkdownSource } from "./source";
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
const urls = createAssistantImageAcquisitionCache<{ uri: string; attachment: AttachmentMetadata }>({
capacity: 500,
onRetain: ({ uri, attachment }) => {
const release = retainAttachmentForGarbageCollection(attachment.id);
return () => {
release();
void releaseAttachmentPreviewUrl({ attachment, url: uri });
};
},
});
export interface MarkdownImageContext {
client?: Pick<DaemonClient, "readFile"> | null;
workspaceRoot?: string;
serverId?: string;
filePath?: string;
}
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
[text, context.workspaceRoot],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
const resolved = resolveMarkdownPreviewImageSource({
source,
markdownPath: context.filePath ?? "",
workspaceRoot: context.workspaceRoot,
});
return createAssistantImageFileAcquisition({
port: context.client
? {
readFile: (cwd, path) => context.client!.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null,
resolution: resolved
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
: null,
serverId: context.serverId,
occurrenceKey,
unavailableMessage: "Image unavailable",
});
});
const results = useFetchQueries(
acquisitions.map((acquisition, index) => ({
queryKey: ["markdownImage", acquisition?.key ?? sources[index]],
enabled: acquisition !== null,
immutableWhen: () => true,
dataShape: "value",
gcTime: 0,
queryFn: async () => {
if (!acquisition) throw new Error("No image acquisition");
return urls.acquire(acquisition.key, async () => {
const attachment = await acquisition.locate();
const uri = await resolveAttachmentPreviewUrl(attachment);
return { attachment, uri };
});
},
})),
);
useEffect(() => {
const releases = results.flatMap((result, index) => {
const acquisition = acquisitions[index];
if (!result.data || !acquisition) return [];
const value = result.data;
return [urls.acquireRetained(acquisition.key, async () => value).release];
});
return () => releases.forEach((release) => release());
}, [results, acquisitions]);
const resolved = new Map<string, string>();
results.forEach((result, index) => {
if (result.data) resolved.set(sources[index]!, result.data.uri);
});
const displayUrls = sources.map(
(source) =>
resolved.get(source) ?? (/^(https?:|data:|blob:)/i.test(source) ? source : undefined),
);
const decoded = useFetchQueries(
displayUrls.map((uri, index) => ({
queryKey: ["markdownImageDecode", occurrenceKey, uri ?? sources[index]],
refetchOnReconnect: true,
enabled: uri !== undefined,
immutableWhen: () => true,
dataShape: "value",
gcTime: 0,
queryFn: () => {
if (!uri) throw new Error("No image URL to decode");
return decodeMarkdownImage(uri, Image.getSize);
},
})),
);
const statuses = sources.map((_source, index) =>
getMarkdownImageStatus({
canAcquire: acquisitions[index] !== null,
url: displayUrls[index],
acquisitionFailed: results[index].isError,
decodeFailed: decoded[index].isError,
decoded: decoded[index].data === true,
}),
);
const unavailableImages = new Set(
sources.filter((_source, index) => statuses[index] === "unavailable"),
);
const pendingImages = new Set(sources.filter((_source, index) => statuses[index] === "pending"));
return {
markdown: prepared.render(resolved, unavailableImages, pendingImages),
linkRecognition: prepared.linkRecognition,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
}
@@ -1,62 +0,0 @@
export interface InlineImageDimensions {
width: number;
height: number;
}
export interface InlineImageExplicitDimensions {
width?: number;
height?: number;
}
const INLINE_IMAGE_MAX_WIDTH = 240;
const INLINE_IMAGE_MAX_HEIGHT = 160;
export function resolveInlineImageSize(input: {
explicit: InlineImageExplicitDimensions;
natural: InlineImageDimensions | null;
}): InlineImageDimensions {
const dimensions = resolveInlineImageDimensions(input);
const scale = Math.min(
1,
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
);
return {
width: Math.round(dimensions.width * scale),
height: Math.round(dimensions.height * scale),
};
}
function resolveInlineImageDimensions(input: {
explicit: InlineImageExplicitDimensions;
natural: InlineImageDimensions | null;
}): InlineImageDimensions {
if (input.explicit.width && input.explicit.height) {
return { width: input.explicit.width, height: input.explicit.height };
}
if (input.explicit.width && input.natural) {
return {
width: input.explicit.width,
height: input.explicit.width * (input.natural.height / input.natural.width),
};
}
if (input.explicit.height && input.natural) {
return {
width: input.explicit.height * (input.natural.width / input.natural.height),
height: input.explicit.height,
};
}
if (input.explicit.width) {
return { width: input.explicit.width, height: input.explicit.width };
}
if (input.explicit.height) {
return { width: input.explicit.height, height: input.explicit.height };
}
return input.natural ?? { width: INLINE_IMAGE_MAX_WIDTH, height: INLINE_IMAGE_MAX_HEIGHT };
}

Some files were not shown because too many files have changed in this diff Show More