mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
Merge branch 'main' into fix/plugin-type-walk-node-builtins
This commit is contained in:
@@ -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```",
|
||||
``,
|
||||
].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">');
|
||||
|
||||
@@ -33,9 +33,10 @@ const STREAMED_RESPONSE = [
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & 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();
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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"));
|
||||
const growing = harness.send(assistant("\n\nAfter\n"));
|
||||
const last = harness.send(assistant("- item"));
|
||||
harness.send(assistant("Intro\n\n"));
|
||||
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: "", 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\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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "",
|
||||
id: "measured",
|
||||
text: "First paragraph\n\n",
|
||||
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",
|
||||
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,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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 +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;
|
||||
}
|
||||
-2
@@ -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 = "";
|
||||
|
||||
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 ``;
|
||||
}
|
||||
|
||||
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("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
+55
-1
@@ -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(``);
|
||||
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
Reference in New Issue
Block a user