mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5fb20cc55 | ||
|
|
307f0dee0d | ||
|
|
d2169a061c | ||
|
|
ee4a5bca7a | ||
|
|
b782963970 | ||
|
|
3d06f652b2 | ||
|
|
554530a667 | ||
|
|
35302f7251 | ||
|
|
096508240f | ||
|
|
3639084530 | ||
|
|
2db3488dbd | ||
|
|
bf100e1752 | ||
|
|
ab234d5f12 | ||
|
|
633950bb6a | ||
|
|
1fbdac728f | ||
|
|
9b7a6ba782 | ||
|
|
aeaee471a6 | ||
|
|
9bbfe29c0e | ||
|
|
d42e0ca00c | ||
|
|
eedf916023 | ||
|
|
7023d20a5b | ||
|
|
5bf0e4b588 | ||
|
|
9ef289e7f4 | ||
|
|
ae649cf7ec | ||
|
|
83061e0312 | ||
|
|
e831814d73 | ||
|
|
75907b360b | ||
|
|
d8f79925d1 |
@@ -49,6 +49,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
|
||||
@@ -12,7 +12,7 @@ provider deltas (every provider streams incrementally)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ source-item plugin transforms → native Markdown blocks / tool grouping
|
||||
→ source-item plugin transforms → Markdown presentation / tool grouping
|
||||
→ paced reveal (app, per displayed item) → paint
|
||||
```
|
||||
|
||||
@@ -29,23 +29,8 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **One source assistant message is one display row.** Presentation projects plugin output and caches unchanged history. The shared [Markdown renderer](markdown-renderer.md) owns rendering on every platform. Chat paces the visible source and applies its 32,000-character render cap; it does not split Markdown or promote completed blocks into history.
|
||||
- **Message identity survives completion.** Assistant row ids are source message ids. Plugin rows can replace a source, so find, scroll-to-message and history reveal still use `getStreamItemMessageId`. Width-specific measurements describe whole rendered messages.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Markdown rendering
|
||||
|
||||
Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and changelog content. It uses `react-native-enriched-markdown` on iOS, Android and web. Keep source adaptation and renderer configuration in `components/markdown`; consumers supply Markdown and, when needed, its file location.
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat still owns paced reveal, its render cap, message identity and list measurement; it does not choose a Markdown engine or classify prose.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars.
|
||||
|
||||
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. HTML and details collapsibles have no custom renderer.
|
||||
|
||||
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; selection stops at a diagram boundary. Streaming keeps the last valid diagram visible while the next revision renders.
|
||||
|
||||
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
|
||||
|
||||
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
Follow [QA](qa.md) and [mobile testing](mobile-testing.md). Exercise chat, file preview, plan and PR content on all three platforms. Include cross-paragraph selection, progressive streaming, a file-link tap, code copy/highlighting, a table and a workspace image. On iPad, repeat preview selection after opening and closing Explorer and after rotation. Check web find, selection-copy and source-message navigation after DOM changes.
|
||||
Generated
+41
-68
@@ -16265,15 +16265,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001777",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
|
||||
@@ -17088,15 +17079,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
@@ -17122,17 +17104,6 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelize": "^1.0.0",
|
||||
"css-color-keywords": "^1.0.0",
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
@@ -30248,6 +30219,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -30259,6 +30231,7 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
@@ -30905,15 +30878,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-fit-image": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||
"integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==",
|
||||
"license": "Beerware",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||
@@ -30939,22 +30903,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-markdown-display": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz",
|
||||
"integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-to-react-native": "^3.0.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-native-fit-image": "^1.5.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.2.0",
|
||||
"react-native": ">=0.50.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.35.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.5.tgz",
|
||||
@@ -31020,18 +30968,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-uitextview": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz",
|
||||
"integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-unistyles": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
||||
@@ -36480,7 +36416,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",
|
||||
@@ -36490,15 +36430,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",
|
||||
@@ -37350,6 +37289,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
|
||||
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/miniflare": {
|
||||
"version": "4.20260625.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
|
||||
@@ -37378,6 +37334,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/app/node_modules/react-native-enriched-markdown": {
|
||||
"version": "1.2.0-nightly-20261014-766fc0e67",
|
||||
"resolved": "https://registry.npmjs.org/react-native-enriched-markdown/-/react-native-enriched-markdown-1.2.0-nightly-20261014-766fc0e67.tgz",
|
||||
"integrity": "sha512-PyYAAVJAyjlcqHXgSnzxok13MDmyxs/nt46hHIOBdCc0prfaL2Ef6nYkZu7fFCeaETOJXd/xQr4n2/79I+gvdA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"katex": ">=0.16.0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"katex": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/react-native-keyboard-controller": {
|
||||
"version": "1.21.12",
|
||||
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.12.tgz",
|
||||
|
||||
@@ -97,11 +97,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 +133,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,
|
||||
}) => {
|
||||
@@ -194,14 +194,15 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
}
|
||||
};
|
||||
const inspect = () => {
|
||||
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
|
||||
(element) => element.getAttribute("aria-label") === accessibleName,
|
||||
const target = Array.from(document.querySelectorAll("img[alt]")).find(
|
||||
(element) => element.getAttribute("alt") === accessibleName,
|
||||
);
|
||||
if (!target || target === state.target) {
|
||||
record();
|
||||
return;
|
||||
}
|
||||
state.target = target;
|
||||
target.addEventListener("load", record, { once: true });
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(record);
|
||||
state.resizeObserver.observe(target);
|
||||
@@ -235,7 +236,6 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.reload();
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await page.evaluate(() => {
|
||||
const state = (
|
||||
@@ -246,16 +246,20 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
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,
|
||||
);
|
||||
const geometryEvents = events as Array<{
|
||||
complete: boolean;
|
||||
height: number;
|
||||
scrollTop: number;
|
||||
}>;
|
||||
const loadedIndex = geometryEvents.findIndex((event) => event.complete && event.height > 0);
|
||||
expect(loadedIndex, JSON.stringify(events)).toBeGreaterThanOrEqual(0);
|
||||
const beforeLoad = geometryEvents[Math.max(0, loadedIndex - 1)]!;
|
||||
const loaded = geometryEvents[loadedIndex]!;
|
||||
expect(
|
||||
new Set(geometryEvents.filter((event) => event.height > 0).map((event) => event.height)).size,
|
||||
).toBe(1);
|
||||
expect(loaded.height).toBe(beforeLoad.height);
|
||||
expect(loaded.scrollTop).toBe(beforeLoad.scrollTop);
|
||||
});
|
||||
|
||||
test("a real assistant PNG remains reachable through pagination and remount", async ({
|
||||
@@ -296,3 +300,26 @@ 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);
|
||||
});
|
||||
|
||||
@@ -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">');
|
||||
@@ -512,7 +491,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
" - Switch Claude models and confirm each model retains its own thinking choice.",
|
||||
" - Cover new-workspace drafts, queued launches, schedules, and editing existing schedules.",
|
||||
" - Confirm Opus 5 exposes the Fast toggle.",
|
||||
" - Smoke with an older saved Fabel/model alias and with daemon/client version drift.",
|
||||
" - Smoke with an older saved [Fabel/model](Fabel/model) alias and with [daemon/client](daemon/client) version drift.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(headingAndListClipboard.html).toMatch(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@ 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 +18,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 +29,64 @@ test.describe("Plan card markdown", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("chat and file preview share the library renderer and resolve file links", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
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`.\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);
|
||||
await openAgentRoute(page, session);
|
||||
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 message.getByRole("link", { name: "README.md" }).click();
|
||||
const preview = page.getByTestId("markdown-preview-frame");
|
||||
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
|
||||
await expect(preview.getByRole("table")).toContainText("42");
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "src/说明.md:2-3" }).click();
|
||||
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "Space file", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Explicit link opened the space filename.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
|
||||
await message.getByRole("link", { name: "my%20file.ts", exact: true }).click();
|
||||
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",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -124,7 +121,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 +169,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 +250,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");
|
||||
}
|
||||
|
||||
@@ -491,7 +491,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");
|
||||
@@ -508,8 +508,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1132,7 +1132,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
imageContext: { serverId: resolvedServerId, workspaceRoot },
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
@@ -11,6 +5,7 @@ export {
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
useAssistantFileLinkEnvironment,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,55 @@
|
||||
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: Definition[] = [];
|
||||
let cursor = 0;
|
||||
function visit(node: RootContent): void {
|
||||
if (node.type === "definition") definitions.push(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),
|
||||
});
|
||||
// Reference links keep their document-wide scope on both sides of a diagram.
|
||||
if (segments.some((segment) => segment.kind === "mermaid")) {
|
||||
const referenceSource = definitions
|
||||
.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 += `\n\n${referenceSource}`;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+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,99 @@
|
||||
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";
|
||||
|
||||
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),
|
||||
[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);
|
||||
});
|
||||
return {
|
||||
markdown: prepared.render(resolved),
|
||||
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 };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
|
||||
export function colorMarkdownLinkChildren(
|
||||
children: ReactNode,
|
||||
color: TextStyle["color"],
|
||||
): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child;
|
||||
return cloneElement(child, { style: [child.props.style, { color }] });
|
||||
});
|
||||
}
|
||||
|
||||
const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" };
|
||||
|
||||
export function markdownLinkTextStyle(
|
||||
style: StyleProp<TextStyle>,
|
||||
hovered: boolean,
|
||||
): StyleProp<TextStyle> {
|
||||
return [style, hovered && MARKDOWN_LINK_HOVER_STYLE];
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Pressable, Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { markdownLinkTextStyle } from "./link-children";
|
||||
|
||||
interface MarkdownLinkTextProps {
|
||||
style: StyleProp<TextStyle>;
|
||||
dataSet?: Record<string, string>;
|
||||
onPress(): void;
|
||||
onHoverIn?(): void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownLinkText({
|
||||
style,
|
||||
dataSet,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
children,
|
||||
}: MarkdownLinkTextProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
onHoverIn?.();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text dataSet={dataSet} style={textStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
@@ -1,99 +1,141 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { Text, type StyleProp, type TextStyle } from "react-native";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareMarkdownSource } from "./source";
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Pressable: ({
|
||||
accessibilityRole,
|
||||
children,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityRole?: string;
|
||||
children?: ReactNode;
|
||||
onHoverIn?(): void;
|
||||
onHoverOut?(): void;
|
||||
onPress?(): void;
|
||||
}) =>
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
onClick: onPress,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) =>
|
||||
createElement("span", { style: flattenStyle(style) }, children),
|
||||
}));
|
||||
|
||||
function flattenStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style]));
|
||||
}
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
it("resolves workspace images without rewriting code or remote image URLs", () => {
|
||||
const source =
|
||||
"\n\n\n\n```md\n\n```";
|
||||
const prepared = prepareMarkdownSource(source);
|
||||
expect(prepared.images.map((image) => image.source)).toEqual([
|
||||
"assets/a.png",
|
||||
"https://example.com/a.png",
|
||||
]);
|
||||
expect(prepared.render(new Map([["assets/a.png", "blob:cached"]]))).toBe(
|
||||
"\n\n\n\n```md\n\n```",
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves a capped 3:2 placeholder when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
it("resolves reference images and images nested in lists and quotes", () => {
|
||||
const prepared = prepareMarkdownSource("> - ![Image][picture]\n\n[picture]: ../a.png");
|
||||
expect(prepared.render(new Map([["../a.png", "file:///cache/a.png"]]))).toBe(
|
||||
"> - \n\n[picture]: ../a.png",
|
||||
);
|
||||
});
|
||||
it("escapes spoiler delimiters only in prose", () => {
|
||||
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
|
||||
"Show \\|\\|words\\|\\| and `||code||`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Markdown links", () => {
|
||||
it("renders accent text and underlines it while hovered", () => {
|
||||
const onPress = vi.fn();
|
||||
const children = colorMarkdownLinkChildren(
|
||||
createElement(Text, { style: { color: "white" } }, "Paseo"),
|
||||
"rgb(0, 122, 255)",
|
||||
);
|
||||
const view = render(
|
||||
createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children),
|
||||
);
|
||||
const link = view.getByRole("link");
|
||||
const linkText = link.firstElementChild as HTMLElement;
|
||||
it("preserves named and numeric entities as literal rendered text", () => {
|
||||
expect(prepareMarkdownSource("AT&T 😀 # heading and `&`").render()).toBe(
|
||||
"AT\\&T 😀 \\# heading and `&`",
|
||||
);
|
||||
});
|
||||
|
||||
expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)");
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
it("preserves empty table cells while escaping spoilers in cell text", () => {
|
||||
const source = "| A || B |\n| --- | --- | --- |\n| x || z |";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(
|
||||
"| A | | B |\n| --- | --- | --- |\n| x | | z |",
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("underline");
|
||||
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(linkText.style.textDecorationLine).toBe("");
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(onPress).toHaveBeenCalledOnce();
|
||||
it.each([
|
||||
"src/components/message.tsx",
|
||||
"C:\\repo\\file.ts",
|
||||
"docs/说明.md",
|
||||
"my%20file.ts",
|
||||
"src/file.ts:10-20",
|
||||
"src/file.ts(10,2)",
|
||||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
|
||||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||||
href: path,
|
||||
text: path,
|
||||
sourceType: "inline-code",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||||
"说明.md",
|
||||
]);
|
||||
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit and inline-code links distinct when their URL strings match", () => {
|
||||
const url = "/tmp/my%20file.ts";
|
||||
const prepared = prepareMarkdownSource(`[open](${url}) and \`${url}\``);
|
||||
expect(prepared.linkSource(url, "markdown")).toEqual({ href: url, text: url });
|
||||
expect(prepared.linkSource(url, "inline-code")).toEqual({
|
||||
href: url,
|
||||
text: url,
|
||||
sourceType: "inline-code",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts only Mermaid fences for the native diagram host", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const source =
|
||||
"First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last.";
|
||||
expect(splitMermaidFences(source)).toEqual([
|
||||
{ kind: "markdown", id: "0", source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
|
||||
{ kind: "mermaid", id: "1", source: "graph TD\nA-->B" },
|
||||
{ kind: "markdown", id: "2", source: "\n\n> Last." },
|
||||
]);
|
||||
expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([
|
||||
{ kind: "markdown", id: "0", source: "```md\n```mermaid\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps reference links available across native Mermaid boundaries", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const parts = splitMermaidFences(
|
||||
"[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts",
|
||||
);
|
||||
for (const part of parts.filter((candidate) => candidate.kind === "markdown"))
|
||||
expect(part.source).toContain("[file]: <src/file.ts>");
|
||||
});
|
||||
|
||||
it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([
|
||||
{ kind: "mermaid", id: "0", source: "graph TD\nA-->B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries reference definitions nested in quotes across a diagram", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const parts = splitMermaidFences(
|
||||
"[open][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> [file]: src/file.ts",
|
||||
);
|
||||
expect(parts[0].source).toContain("[file]: <src/file.ts>");
|
||||
});
|
||||
|
||||
it("keeps diagram identity when an earlier workspace image resolves", async () => {
|
||||
const { splitMermaidFences } = await import("./fence/mermaid/segments");
|
||||
const source = "\n\n```mermaid\ngraph TD\nA-->B\n```";
|
||||
const before = splitMermaidFences(source).find((part) => part.kind === "mermaid");
|
||||
const after = splitMermaidFences(
|
||||
source.replace("a.png", "file:///long/cache/path/image.png"),
|
||||
).find((part) => part.kind === "mermaid");
|
||||
expect(before).toHaveProperty("id");
|
||||
expect(after?.id).toBe(before?.id);
|
||||
});
|
||||
|
||||
it("keeps workspace-relative hover titles on explicit absolute file links", () => {
|
||||
expect(prepareMarkdownSource("[Open](/repo/src/file.ts:42-45)", "/repo").render()).toBe(
|
||||
'[Open](/repo/src/file.ts:42-45 "src/file.ts:42-45")',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['""', "''", "()"])("preserves an authored empty link title: %s", (title) => {
|
||||
const source = `[Open](/repo/file.ts ${title})`;
|
||||
expect(prepareMarkdownSource(source, "/repo").render()).toBe(source);
|
||||
});
|
||||
|
||||
@@ -1,790 +1,197 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
EnrichedMarkdownText,
|
||||
type MarkdownStyle,
|
||||
type LinkPressEvent,
|
||||
type CodeBlockRendererProps,
|
||||
} from "react-native-enriched-markdown";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
useAssistantFileLinkActions,
|
||||
useAssistantFileLinkEnvironment,
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
import { colorMarkdownLinkChildren } from "./link-children";
|
||||
import { MarkdownLinkText } from "./link-text";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useMarkdownSource, type MarkdownImageContext } from "./images";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fence/mermaid";
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
// Serves PR comment bodies and the markdown file preview; agent chat passes its
|
||||
// own parser. The preview has to show the bytes on disk, so no typographer.
|
||||
const defaultMarkdownParser = createMarkdownParser({ linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return <MarkdownPartList parts={parts} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
return groups.map((group, index) => ({ key: `${group.kind}:${index}`, group }));
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const cached = useMemo(() => getAssistantImageMetadata({ source: part.src }), [part.src]);
|
||||
const [resolved, setResolved] = useState<{
|
||||
source: string;
|
||||
dimensions: InlineImageDimensions;
|
||||
} | null>(null);
|
||||
const natural = resolved?.source === part.src ? resolved.dimensions : cached;
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((part.width && part.height) || cached) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
setAssistantImageMetadata({ source: part.src }, { width, height });
|
||||
if (!cancelled) {
|
||||
setResolved({ source: part.src, dimensions: { width, height } });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cached, part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]);
|
||||
|
||||
if (!isNative) {
|
||||
return (
|
||||
<MarkdownLinkText style={style} onPress={handlePress}>
|
||||
{children}
|
||||
</MarkdownLinkText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
function markdownStyle(theme: Theme, compact = false): { markdownStyle: MarkdownStyle } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
const paragraph = {
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[3],
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
...paragraph,
|
||||
fontSize: Math.round(fontSize * scale),
|
||||
lineHeight: Math.round(fontSize * scale * 1.3),
|
||||
fontWeight: "bold",
|
||||
marginTop: theme.spacing[3],
|
||||
});
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownFenceBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
info={node.sourceInfo}
|
||||
phase="complete"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
markdownStyle: {
|
||||
paragraph,
|
||||
h1: heading(1.6),
|
||||
h2: heading(1.4),
|
||||
h3: heading(1.2),
|
||||
h4: heading(1.1),
|
||||
h5: heading(1),
|
||||
h6: heading(1),
|
||||
list: {
|
||||
...paragraph,
|
||||
bulletColor: theme.colors.foreground,
|
||||
markerColor: theme.colors.foregroundMuted,
|
||||
gapWidth: 7,
|
||||
itemSpacing: 4,
|
||||
marginLeft: 20,
|
||||
},
|
||||
blockquote: {
|
||||
...paragraph,
|
||||
color: theme.colors.foregroundMuted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
borderWidth: 3,
|
||||
padding: 12,
|
||||
gapWidth: 8,
|
||||
},
|
||||
link: { color: theme.colors.accent, underline: false },
|
||||
linkVariants: {
|
||||
"^(?!https?://).+": {
|
||||
color: theme.colors.accent,
|
||||
pill: { borderRadius: 4, paddingHorizontal: 3 },
|
||||
},
|
||||
},
|
||||
code: {
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
codeBlock: {
|
||||
...paragraph,
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
table: {
|
||||
...paragraph,
|
||||
borderColor: theme.colors.border,
|
||||
headerBackgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
|
||||
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
|
||||
},
|
||||
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_th}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_td}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{colorMarkdownLinkChildren(children, styles.link.color)}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
const regularStyle = (theme: Theme) => markdownStyle(theme);
|
||||
const compactStyle = (theme: Theme) => markdownStyle(theme, true);
|
||||
const ThemedCodeBlock = withUnistyles(HighlightedCodeBlock);
|
||||
const codeStyle = (theme: Theme) => ({
|
||||
inheritedStyles: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
|
||||
});
|
||||
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
|
||||
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
|
||||
}
|
||||
const WEB_CONTAINER = { userSelect: "text" as const };
|
||||
const WEB_RENDERERS = { codeBlock: WebCodeBlock };
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
imageContext.client ??
|
||||
(environment.client?.readFile
|
||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||
: undefined);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
});
|
||||
const pressImage = useStableEvent(({ url }: { url: string }) =>
|
||||
setImage({ type: "uri", uri: url }),
|
||||
);
|
||||
const closeImage = useStableEvent(() => setImage(null));
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
|
||||
[markdown],
|
||||
);
|
||||
const content = (
|
||||
<MermaidStreamingContext.Provider value={streaming}>
|
||||
{segments.map((segment) =>
|
||||
segment.kind === "mermaid" ? (
|
||||
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
|
||||
) : (
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
|
||||
</MermaidStreamingContext.Provider>
|
||||
);
|
||||
return isWeb && Object.keys(imageSources).length > 0 ? (
|
||||
<View dataSet={markdownCopyImageSourcesDataSet(imageSources)}>{content}</View>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import type { Link, Nodes, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
const FILE_PATTERN =
|
||||
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@%-]+\.[a-zA-Z0-9\u0080-\uFFFF_%-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
|
||||
|
||||
function linkMetadata() {
|
||||
return {
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
|
||||
),
|
||||
},
|
||||
linkSource(
|
||||
url: string,
|
||||
sourceType?: "markdown" | "text" | "inline-code",
|
||||
): AssistantFileLinkSource {
|
||||
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
|
||||
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
|
||||
return { href: url, text: url };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MarkdownImage {
|
||||
source: string;
|
||||
alt: string;
|
||||
title: string | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string): string | null {
|
||||
if (link.title !== null || !workspaceRoot || !link.url.startsWith(`${workspaceRoot}/`))
|
||||
return null;
|
||||
// mdast represents both missing titles and authored empty titles as null.
|
||||
if (/\s(?:""|''|\(\))\s*\)$/.test(markup)) return null;
|
||||
return link.url.slice(workspaceRoot.length + 1);
|
||||
}
|
||||
|
||||
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
|
||||
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
|
||||
const images: MarkdownImage[] = [];
|
||||
let tree: Root | undefined;
|
||||
const getTree = () =>
|
||||
(tree ??= fromMarkdown(source, {
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes(")
|
||||
) {
|
||||
return { images, ...linkMetadata(), render: () => source };
|
||||
}
|
||||
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
function definitionsIn(node: Nodes) {
|
||||
if (node.type === "definition") definitions.set(node.identifier, node);
|
||||
if ("children" in node) node.children.forEach(definitionsIn);
|
||||
}
|
||||
definitionsIn(getTree());
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
edits.push({ start: end - 1, end: end - 1, text: ` ${JSON.stringify(title)}` });
|
||||
}
|
||||
if (node.type === "tableRow") {
|
||||
// MD4C's always-enabled spoiler extension otherwise consumes adjacent cell
|
||||
// delimiters. Spaces keep empty GFM cells empty without producing spoilers.
|
||||
const row = source.slice(start, end);
|
||||
for (const match of row.matchAll(/(?<!\\)\|(?=\|)/g)) {
|
||||
edits.push({ start: start + match.index + 1, end: start + match.index + 1, text: " " });
|
||||
}
|
||||
}
|
||||
if (node.type === "image" || node.type === "imageReference") {
|
||||
const target = node.type === "image" ? node : definitions.get(node.identifier);
|
||||
if (target)
|
||||
images.push({
|
||||
source: target.url,
|
||||
alt: node.alt ?? "",
|
||||
title: target.title ?? null,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.type === "text") {
|
||||
const text = source
|
||||
.slice(start, end)
|
||||
.replace(/(?<!\\)&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
|
||||
decodeString(entity).replace(/[!-/:-@[-`{-~]/g, "\\$&"),
|
||||
)
|
||||
.replace(/(?<!\\)\|\|/g, "\\|\\|");
|
||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
||||
}
|
||||
}
|
||||
if ("children" in node) node.children.forEach(visit);
|
||||
}
|
||||
visit(getTree());
|
||||
return {
|
||||
images,
|
||||
...linkMetadata(),
|
||||
render(urls: ReadonlyMap<string, string> = new Map()) {
|
||||
const replacements = [...edits];
|
||||
for (const image of images) {
|
||||
if (/^(https?:|data:|blob:)/i.test(image.source)) continue;
|
||||
const url = urls.get(image.source);
|
||||
replacements.push({
|
||||
start: image.start,
|
||||
end: image.end,
|
||||
text: url
|
||||
? `![${image.alt.replace(/[[\]\\]/g, "\\$&")}](<${url}>${image.title ? ` ${JSON.stringify(image.title)}` : ""})`
|
||||
: image.alt,
|
||||
});
|
||||
}
|
||||
let result = source;
|
||||
for (const edit of replacements.sort((a, b) => b.start - a.start)) {
|
||||
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +1,11 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { type ASTNode } from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
// Without this prop react-native-markdown-display builds its own parser with
|
||||
// `typographer: true`, which would render a plan's literal `(c)` as ©. Its
|
||||
// default also leaves linkify off, so this one keeps bare URLs as plain text.
|
||||
const planMarkdownParser = createMarkdownParser({ linkify: false });
|
||||
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function MarkdownInlineText({
|
||||
inheritedStyle,
|
||||
ruleStyle,
|
||||
children,
|
||||
}: {
|
||||
inheritedStyle: StyleProp<TextStyle>;
|
||||
ruleStyle: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
|
||||
return <Text style={style}>{children}</Text>;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({
|
||||
contentStyle,
|
||||
children,
|
||||
}: {
|
||||
contentStyle: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo(() => [contentStyle, LIST_ITEM_CONTENT_INNER], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
function MarkdownParagraph({
|
||||
paragraphStyle,
|
||||
isLastChild,
|
||||
children,
|
||||
}: {
|
||||
paragraphStyle: StyleProp<ViewStyle>;
|
||||
isLastChild: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const style = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
|
||||
[paragraphStyle, isLastChild],
|
||||
);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.text}>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_block}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText key={node.key} inheritedStyle={inheritedStyles} ruleStyle={styles.fence}>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInlineText
|
||||
key={node.key}
|
||||
inheritedStyle={inheritedStyles}
|
||||
ruleStyle={styles.code_inline}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInlineText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownRuleStyles,
|
||||
) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<MarkdownParagraph
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
isLastChild={isLastChild}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraph>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type PlanOutcome = "pending" | "approved" | "rejected" | "canceled";
|
||||
|
||||
@@ -204,7 +21,6 @@ interface PlanCardProps {
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const chevronColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
|
||||
export function PlanCard(props: PlanCardProps) {
|
||||
// A resolution starts its own presentation state; subsequent taps stay local.
|
||||
@@ -262,7 +78,7 @@ function PlanCardContent({
|
||||
{expanded ? (
|
||||
<View style={styles.body}>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
<MarkdownRenderer text={text} rules={markdownRules} markdownit={planMarkdownParser} />
|
||||
<MarkdownRenderer text={text} />
|
||||
</View>
|
||||
) : null}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
@@ -307,6 +123,3 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const LIST_ITEM_CONTENT_INNER = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
const PARAGRAPH_LAST_CHILD = { marginBottom: 0 };
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
createSharedMarkdownRules,
|
||||
MarkdownRenderer,
|
||||
type MarkdownStyles,
|
||||
} from "@/components/markdown/renderer";
|
||||
import { parseMarkdownPreviewDocument } from "./document";
|
||||
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
import { FileMarkdownPreviewImage } from "./preview-image";
|
||||
|
||||
export function FileMarkdownPreview({
|
||||
source,
|
||||
@@ -26,34 +19,6 @@ export function FileMarkdownPreview({
|
||||
serverId?: string;
|
||||
}) {
|
||||
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
||||
const rules = useMemo<RenderRules>(
|
||||
() => ({
|
||||
...createSharedMarkdownRules(),
|
||||
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
|
||||
const resolved = resolveMarkdownPreviewImageSource({
|
||||
source: String(node.attributes?.src ?? ""),
|
||||
markdownPath: filePath,
|
||||
workspaceRoot,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileMarkdownPreviewImage
|
||||
key={node.key}
|
||||
source={resolved}
|
||||
occurrenceKey={`${filePath}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, filePath, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.outerGutter}>
|
||||
@@ -79,7 +44,13 @@ export function FileMarkdownPreview({
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<MarkdownRenderer text={document.body} rules={rules} />
|
||||
<MarkdownRenderer
|
||||
text={document.body}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
export function FileMarkdownPreviewImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
alt,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
alt?: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const image = useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||
const imageUri = binding?.uri ?? "";
|
||||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.surface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
|
||||
<Text style={styles.errorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.frame} testID="markdown-preview-image">
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={styles.loadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
frame: {
|
||||
width: "100%",
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
surface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
state: {
|
||||
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TextRevealState,
|
||||
visibleRevealedText,
|
||||
} from "@/agent-stream/text-reveal";
|
||||
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
||||
export type MarkdownPhase = "streaming" | "complete";
|
||||
|
||||
/**
|
||||
* Binds the paced reveal in @/agent-stream/text-reveal to a frame clock.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
useComposerTabFullscreen,
|
||||
@@ -3626,6 +3627,10 @@ function WorkspaceScreenContent({
|
||||
</View>
|
||||
);
|
||||
|
||||
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
|
||||
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
|
||||
});
|
||||
|
||||
const renderedWorkspaceScreen = (
|
||||
<RenderProfile id="WorkspaceScreenContent">
|
||||
<View style={containerStyle}>
|
||||
@@ -3665,7 +3670,15 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
return (
|
||||
<WorkspaceContentProviders key={persistenceKey} workspaceKey={persistenceKey}>
|
||||
{renderedWorkspaceScreen}
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
workspaceRoot={workspaceDirectory ?? undefined}
|
||||
onOpenWorkspaceFile={openMarkdownFile}
|
||||
toast={toast}
|
||||
>
|
||||
{renderedWorkspaceScreen}
|
||||
</AssistantFileLinkResolverProvider>
|
||||
</WorkspaceContentProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-styles";
|
||||
import { darkTheme } from "./theme";
|
||||
|
||||
describe("createMarkdownStyles", () => {
|
||||
it("uses the content size for conversation prose and list markers", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
const proseLineHeight = Math.round(darkTheme.fontSize.content * 1.4);
|
||||
|
||||
expect(styles.body).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
expect(styles.bullet_list_icon).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
expect(styles.ordered_list_icon).toMatchObject({
|
||||
fontSize: darkTheme.fontSize.content,
|
||||
lineHeight: proseLineHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies shrink-and-wrap constraints to long markdown text and links", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
|
||||
expect(styles.body).toMatchObject({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
expect(styles.paragraph).toMatchObject({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
flexWrap: "wrap",
|
||||
});
|
||||
|
||||
expect(styles.text).toMatchObject({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere",
|
||||
});
|
||||
|
||||
expect(styles.link).toMatchObject({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere",
|
||||
});
|
||||
|
||||
expect(styles.blocklink).toMatchObject({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps assistant markdown text selectable on web", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
|
||||
expect(styles.body).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.text).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.heading1).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.link).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.code_inline).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.code_block).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.fence).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.bullet_list_icon).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
expect(styles.ordered_list_icon).toMatchObject({
|
||||
userSelect: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the mono font-size token directly for inline and block code", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
const compactStyles = createCompactMarkdownStyles(darkTheme);
|
||||
|
||||
expect(styles.code_inline).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
});
|
||||
expect(styles.code_inline).not.toHaveProperty("lineHeight");
|
||||
expect(styles.code_block).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
});
|
||||
expect(styles.fence).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
});
|
||||
expect(compactStyles.code_inline).toMatchObject({
|
||||
fontFamily: darkTheme.fontFamily.mono,
|
||||
fontSize: darkTheme.fontSize.code,
|
||||
});
|
||||
expect(compactStyles.code_inline).not.toHaveProperty("lineHeight");
|
||||
});
|
||||
|
||||
it("scales Markdown headings from content size with safe line heights", () => {
|
||||
const largeContentTheme = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, content: 21 },
|
||||
};
|
||||
const styles = createMarkdownStyles(largeContentTheme);
|
||||
|
||||
expect(styles.heading1.lineHeight).toBeGreaterThan(styles.heading1.fontSize);
|
||||
expect(styles.heading2.lineHeight).toBeGreaterThan(styles.heading2.fontSize);
|
||||
expect(styles.heading3.lineHeight).toBeGreaterThan(styles.heading3.fontSize);
|
||||
});
|
||||
|
||||
it("keeps blockquotes quiet with a square left edge", () => {
|
||||
const styles = createMarkdownStyles(darkTheme);
|
||||
|
||||
expect(styles.blockquote).toMatchObject({
|
||||
backgroundColor: darkTheme.colors.surface1,
|
||||
color: `${darkTheme.colors.foreground}cc`,
|
||||
borderLeftColor: darkTheme.colors.surface2,
|
||||
paddingTop: darkTheme.spacing[3],
|
||||
paddingBottom: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
});
|
||||
expect(styles.paragraph.marginBottom).toBe(darkTheme.spacing[3]);
|
||||
expect(styles.text).not.toHaveProperty("color");
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
import { FONT_SIZE, type Theme } from "./theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
|
||||
|
||||
function contentHeadingSize(contentSize: number, tier: keyof typeof FONT_SIZE): number {
|
||||
return Math.round(contentSize * (FONT_SIZE[tier] / FONT_SIZE.base));
|
||||
}
|
||||
|
||||
function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_SIZE): number {
|
||||
return Math.round(contentHeadingSize(contentSize, tier) * 1.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
*
|
||||
* Usage:
|
||||
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
* <Markdown style={markdownStyles} markdownit={parser}>{content}</Markdown>
|
||||
*
|
||||
* Always pass `markdownit` from `@/utils/markdown-parser`. Omit it and
|
||||
* react-native-markdown-display builds its own parser with `typographer: true`,
|
||||
* which rewrites a literal `(c)` as ©.
|
||||
*/
|
||||
export function createMarkdownStyles(theme: Theme) {
|
||||
return {
|
||||
// =========================================================================
|
||||
// BASE STYLES
|
||||
// =========================================================================
|
||||
|
||||
body: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
// Prose line-height scales with the content size, not the
|
||||
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
text: {
|
||||
...webSelectableTextStyle,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere" as const,
|
||||
},
|
||||
|
||||
paragraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[3],
|
||||
flexWrap: "wrap" as const,
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "flex-start" as const,
|
||||
justifyContent: "flex-start" as const,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// HEADINGS
|
||||
// =========================================================================
|
||||
|
||||
heading1: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "4xl"),
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[6],
|
||||
marginBottom: theme.spacing[3],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "4xl"),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
|
||||
heading2: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "3xl"),
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[6],
|
||||
marginBottom: theme.spacing[3],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "3xl"),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
|
||||
heading3: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
|
||||
},
|
||||
|
||||
heading4: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
|
||||
},
|
||||
|
||||
heading5: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
},
|
||||
|
||||
heading6: {
|
||||
...webSelectableTextStyle,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// TEXT FORMATTING
|
||||
// =========================================================================
|
||||
|
||||
strong: {
|
||||
...webSelectableTextStyle,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
|
||||
em: {
|
||||
...webSelectableTextStyle,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
|
||||
s: {
|
||||
...webSelectableTextStyle,
|
||||
textDecorationLine: "line-through" as const,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
link: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere" as const,
|
||||
},
|
||||
|
||||
blocklink: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.accentBright,
|
||||
textDecorationLine: "none" as const,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
overflowWrap: "anywhere" as const,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// CODE
|
||||
// =========================================================================
|
||||
|
||||
code_inline: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 0,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
|
||||
code_block: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
fence: {
|
||||
...webSelectableTextStyle,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[3],
|
||||
},
|
||||
|
||||
pre: {
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// TABLES
|
||||
// =========================================================================
|
||||
|
||||
table: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginVertical: theme.spacing[3],
|
||||
},
|
||||
|
||||
thead: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
tbody: {},
|
||||
|
||||
th: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
textAlign: "left" as const,
|
||||
},
|
||||
|
||||
tr: {
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
flexDirection: "row" as const,
|
||||
},
|
||||
|
||||
td: {
|
||||
...webSelectableTextStyle,
|
||||
padding: theme.spacing[2],
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.content,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// LISTS
|
||||
// =========================================================================
|
||||
|
||||
bullet_list: {
|
||||
paddingLeft: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
ordered_list: {
|
||||
paddingLeft: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
list_item: {
|
||||
marginBottom: theme.spacing[1],
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "flex-start" as const,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
bullet_list_content: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
ordered_list_content: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
bullet_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
},
|
||||
|
||||
ordered_list_icon: {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.content,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
minWidth: 12,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// BLOCKQUOTE
|
||||
// =========================================================================
|
||||
|
||||
blockquote: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
color: `${theme.colors.foreground}cc`,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.colors.surface2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: 0,
|
||||
marginVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// HORIZONTAL RULE
|
||||
// =========================================================================
|
||||
|
||||
hr: {
|
||||
backgroundColor: theme.colors.border,
|
||||
height: 1,
|
||||
marginVertical: 10,
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// IMAGES
|
||||
// =========================================================================
|
||||
|
||||
image: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// BREAKS
|
||||
// =========================================================================
|
||||
|
||||
hardbreak: {
|
||||
height: theme.spacing[2],
|
||||
},
|
||||
|
||||
softbreak: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a smaller variant of markdown styles for compact UI elements
|
||||
* like thought bubbles, tooltips, or side panels.
|
||||
*/
|
||||
export function createCompactMarkdownStyles(theme: Theme) {
|
||||
const baseStyles = createMarkdownStyles(theme);
|
||||
|
||||
return {
|
||||
...baseStyles,
|
||||
|
||||
body: {
|
||||
...baseStyles.body,
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
},
|
||||
|
||||
heading1: {
|
||||
...baseStyles.heading1,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
|
||||
marginTop: theme.spacing[4],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
|
||||
},
|
||||
|
||||
heading2: {
|
||||
...baseStyles.heading2,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
|
||||
},
|
||||
|
||||
heading3: {
|
||||
...baseStyles.heading3,
|
||||
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
|
||||
},
|
||||
|
||||
paragraph: {
|
||||
...baseStyles.paragraph,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
|
||||
code_inline: {
|
||||
...baseStyles.code_inline,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
|
||||
code_block: {
|
||||
...baseStyles.code_block,
|
||||
fontSize: theme.fontSize.code,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
|
||||
fence: {
|
||||
...baseStyles.fence,
|
||||
fontSize: theme.fontSize.code,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// The runtime sets `sourceInfo` on fence/code nodes (tokensToAST.js sets
|
||||
// `sourceInfo: token.info`), but the shipped .d.ts omits the field. The
|
||||
// re-export is what marks this file as a module so the `declare module`
|
||||
// below augments the package instead of shadowing it.
|
||||
export type { ASTNode } from "react-native-markdown-display";
|
||||
|
||||
declare module "react-native-markdown-display" {
|
||||
interface ASTNode {
|
||||
sourceInfo?: string;
|
||||
}
|
||||
}
|
||||
@@ -712,8 +712,6 @@ export interface AssistantMessageItem {
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
/** Display-only fields, assigned after source-item plugin transforms. */
|
||||
blockGroupId?: string;
|
||||
blockIndex?: number;
|
||||
}
|
||||
|
||||
export interface TimelinePosition {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitMarkdownBlocks } from "../split-markdown-blocks";
|
||||
|
||||
describe("splitMarkdownBlocks", () => {
|
||||
it("returns a single block for a single paragraph", () => {
|
||||
expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]);
|
||||
});
|
||||
|
||||
it("splits two paragraphs separated by a double newline", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
"Second paragraph",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a fenced code block with internal double newlines as one block", () => {
|
||||
expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([
|
||||
"```ts\nconst a = 1;\n\nconst b = 2;\n```",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat 4-space-indented backticks as a fence", () => {
|
||||
expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([
|
||||
"Before",
|
||||
" ```\n code\n ```",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles tilde fences", () => {
|
||||
expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([
|
||||
"Before",
|
||||
"~~~\ncode\n~~~",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph",
|
||||
),
|
||||
).toEqual(["Intro paragraph", "```ts\nconst a = 1;\n\nconst b = 2;\n```", "Outro paragraph"]);
|
||||
});
|
||||
|
||||
it("keeps everything from an unclosed fence start as one block for streaming content", () => {
|
||||
expect(splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;")).toEqual([
|
||||
"Before fence",
|
||||
"```ts\nconst a = 1;\n\nconst b = 2;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(splitMarkdownBlocks("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits a heading followed by a paragraph into two blocks", () => {
|
||||
expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([
|
||||
"# Heading",
|
||||
"Paragraph text",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps consecutive list items together when there is no double newline", () => {
|
||||
expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([
|
||||
"- First item\n- Second item\n- Third item",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a loose nested list in its outer list block", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"Before\n\n3. Outer three\n\n 7. Inner seven\n 8. Inner eight\n\nAfter",
|
||||
),
|
||||
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
|
||||
});
|
||||
|
||||
it("keeps a link reference definition with the paragraph that uses it", () => {
|
||||
expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([
|
||||
"See the [docs][d].\n\n[d]: https://example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds a leading definition-only block into the block below it", () => {
|
||||
expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([
|
||||
'[d]: https://example.com "Docs"\n\nSee the [docs][d].',
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds several definition lines and several definition blocks into one block", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'\n\nAfter",
|
||||
),
|
||||
).toEqual([
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes every destination the renderer accepts, including escaped spaces", () => {
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([
|
||||
"See [docs].\n\n[docs]: docs\\ folder/readme",
|
||||
]);
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: <docs folder/readme> 'Title'")).toEqual([
|
||||
"See [docs].\n\n[docs]: <docs folder/readme> 'Title'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a definition-only message as its own block", () => {
|
||||
expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]);
|
||||
});
|
||||
|
||||
it("does not fold a paragraph that merely starts with a bracketed link", () => {
|
||||
expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([
|
||||
"Intro",
|
||||
"[Link](https://example.com) and more prose",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats triple newlines as a split point and filters empty blocks", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
"Second paragraph",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
estimateAssistantMessageHeightFromCache,
|
||||
extractAssistantImageSources,
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "./assistant-image-metadata";
|
||||
|
||||
describe("assistant image metadata", () => {
|
||||
beforeEach(() => {
|
||||
clearAssistantImageMetadataCache();
|
||||
});
|
||||
|
||||
it("extracts markdown image sources", () => {
|
||||
expect(
|
||||
extractAssistantImageSources(
|
||||
'Before\n\n\n\n',
|
||||
),
|
||||
).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
|
||||
});
|
||||
|
||||
it("keeps local image metadata scoped to its server and workspace", () => {
|
||||
const image = {
|
||||
source: "screenshot.png",
|
||||
workspaceRoot: "/workspace/one",
|
||||
serverId: "server-1",
|
||||
};
|
||||
setAssistantImageMetadata(image, { width: 1200, height: 800 });
|
||||
|
||||
expect(getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })).toBeNull();
|
||||
expect(getAssistantImageMetadata({ ...image, serverId: "server-2" })).toBeNull();
|
||||
expect(getAssistantImageMetadata({ source: image.source })).toBeNull();
|
||||
expect(getAssistantImageMetadata(image)).toEqual({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
setAssistantImageMetadata(
|
||||
{ ...image, workspaceRoot: "/workspace/two" },
|
||||
{ width: 800, height: 1200 },
|
||||
);
|
||||
expect(getAssistantImageMetadata(image)?.aspectRatio).toBe(1.5);
|
||||
expect(
|
||||
getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })?.aspectRatio,
|
||||
).toBe(2 / 3);
|
||||
});
|
||||
|
||||
it("reuses direct image metadata without workspace context", () => {
|
||||
const source = "https://example.com/shared.png";
|
||||
setAssistantImageMetadata(
|
||||
{ source, workspaceRoot: "/workspace/one", serverId: "server-1" },
|
||||
{ width: 1200, height: 800 },
|
||||
);
|
||||
expect(getAssistantImageMetadata({ source })).toEqual({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves the default image aspect ratio before metadata arrives", () => {
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "",
|
||||
contentMaxWidth: 608,
|
||||
}),
|
||||
).toBe(464);
|
||||
});
|
||||
|
||||
it("estimates assistant message height from cached image metadata", () => {
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "https://example.com/landscape.png",
|
||||
},
|
||||
{ width: 1200, height: 800 },
|
||||
);
|
||||
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "Here is the screenshot\n\n",
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
|
||||
it("estimates image-only data-image markdown without caching the full payload as text", () => {
|
||||
const source = `data:image/png;base64,${"a".repeat(512)}`;
|
||||
setAssistantImageMetadata({ source }, { width: 1200, height: 800 });
|
||||
|
||||
const imageOnlyHeight = estimateAssistantMessageHeightFromCache({
|
||||
markdown: ``,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
});
|
||||
const mixedHeight = estimateAssistantMessageHeightFromCache({
|
||||
markdown: `Text\n\n`,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
});
|
||||
|
||||
expect(imageOnlyHeight).toBeGreaterThan(220);
|
||||
expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0);
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import { createImageSourceCacheKey } from "@/attachments/utils";
|
||||
|
||||
export interface AssistantImageContext {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface AssistantImageMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
|
||||
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
|
||||
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
|
||||
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
|
||||
|
||||
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
|
||||
export const ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO = 3 / 2;
|
||||
const ASSISTANT_IMAGE_INSET = 8;
|
||||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
|
||||
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
|
||||
const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
|
||||
const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
|
||||
|
||||
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size <= limit) {
|
||||
return;
|
||||
}
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssistantImageSourceToken(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||
const inner = trimmed.slice(1, -1).trim();
|
||||
return inner || null;
|
||||
}
|
||||
|
||||
const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
|
||||
const source = titleMatch?.[1]?.trim() ?? trimmed;
|
||||
return source || null;
|
||||
}
|
||||
|
||||
function parseAssistantImageMarkdown(markdown: string): {
|
||||
sources: string[];
|
||||
hasNonImageText: boolean;
|
||||
} {
|
||||
const sources: string[] = [];
|
||||
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
|
||||
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
|
||||
if (normalized) {
|
||||
sources.push(normalized);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
hasNonImageText: markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantImageMetadataKey(
|
||||
input: AssistantImageContext & { source: string },
|
||||
): string | null {
|
||||
const resolution = resolveAssistantImageSource({
|
||||
source: input.source,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
if (resolution.kind === "direct") {
|
||||
return `direct:${createImageSourceCacheKey(resolution.uri)}`;
|
||||
}
|
||||
// A file path identifies an image only within its daemon and workspace.
|
||||
// Callers without that context must use the default aspect ratio.
|
||||
if (!input.serverId) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(["file", input.serverId, resolution.cwd, resolution.path]);
|
||||
}
|
||||
|
||||
export function getAssistantImageMetadata(
|
||||
input: AssistantImageContext & { source: string },
|
||||
): AssistantImageMetadata | null {
|
||||
const key = getAssistantImageMetadataKey(input);
|
||||
const metadata = key ? assistantImageMetadataCache.get(key) : undefined;
|
||||
if (!key || !metadata) {
|
||||
return null;
|
||||
}
|
||||
touchCacheEntry(assistantImageMetadataCache, key, metadata, ASSISTANT_IMAGE_METADATA_CACHE_LIMIT);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function setAssistantImageMetadata(
|
||||
input: AssistantImageContext & { source: string },
|
||||
dimensions: { width: number; height: number },
|
||||
): AssistantImageMetadata | null {
|
||||
const { width, height } = dimensions;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata: AssistantImageMetadata = {
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
};
|
||||
|
||||
const key = getAssistantImageMetadataKey(input);
|
||||
if (key) {
|
||||
touchCacheEntry(
|
||||
assistantImageMetadataCache,
|
||||
key,
|
||||
metadata,
|
||||
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractAssistantImageSources(markdown: string): string[] {
|
||||
const shouldCacheParse = !/data:image\//i.test(markdown);
|
||||
const cachedParse = shouldCacheParse ? assistantImageParseCache.get(markdown) : undefined;
|
||||
if (cachedParse) {
|
||||
touchCacheEntry(
|
||||
assistantImageParseCache,
|
||||
markdown,
|
||||
cachedParse,
|
||||
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||
);
|
||||
return cachedParse.sources;
|
||||
}
|
||||
|
||||
const parsed = parseAssistantImageMarkdown(markdown);
|
||||
if (shouldCacheParse) {
|
||||
touchCacheEntry(assistantImageParseCache, markdown, parsed, ASSISTANT_IMAGE_PARSE_CACHE_LIMIT);
|
||||
}
|
||||
return parsed.sources;
|
||||
}
|
||||
|
||||
export interface AssistantMessageHeightEstimateInput {
|
||||
markdown: string;
|
||||
contentMaxWidth: number;
|
||||
imageContext?: AssistantImageContext;
|
||||
}
|
||||
|
||||
export function estimateAssistantMessageHeightFromCache({
|
||||
markdown,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}: AssistantMessageHeightEstimateInput): number | null {
|
||||
const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown);
|
||||
if (parsed.sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageHeights = parsed.sources.map((source) => {
|
||||
const aspectRatio =
|
||||
getAssistantImageMetadata({ source, ...imageContext })?.aspectRatio ??
|
||||
ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO;
|
||||
return Math.max(
|
||||
ASSISTANT_IMAGE_MIN_HEIGHT,
|
||||
Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / aspectRatio),
|
||||
);
|
||||
});
|
||||
|
||||
const baseHeight = parsed.hasNonImageText
|
||||
? ASSISTANT_MESSAGE_BASE_HEIGHT
|
||||
: ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
|
||||
|
||||
const estimatedHeight =
|
||||
baseHeight +
|
||||
imageHeights.reduce((sum, height) => sum + height, 0) +
|
||||
ASSISTANT_IMAGE_BLOCK_GAP * imageHeights.length;
|
||||
|
||||
return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
|
||||
}
|
||||
|
||||
export function clearAssistantImageMetadataCache(): void {
|
||||
assistantImageMetadataCache.clear();
|
||||
assistantImageParseCache.clear();
|
||||
}
|
||||
@@ -2,141 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
|
||||
|
||||
describe("createAssistantMarkdownParser", () => {
|
||||
it("keeps bold text bold through every partial closing marker", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
|
||||
for (const source of ["**bold", "**bold*", "**bold**"]) {
|
||||
expect(parser.renderInline(source)).toBe("<strong>bold</strong>");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the growing link label and only links a complete destination", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
const source = "[docs](https://example.com/path)";
|
||||
for (let length = 1; length < source.length; length++) {
|
||||
expect(parser.renderInline(source.slice(0, length))).toBe(
|
||||
"docs".slice(0, Math.max(0, length - 1)),
|
||||
);
|
||||
}
|
||||
expect(parser.renderInline(source)).toBe('<a href="https://example.com/path">docs</a>');
|
||||
});
|
||||
|
||||
it.each([
|
||||
["**", "strong"],
|
||||
["__", "strong"],
|
||||
["*", "em"],
|
||||
["_", "em"],
|
||||
["~~", "s"],
|
||||
["`", "code"],
|
||||
["``", "code"],
|
||||
])("keeps %s formatting stable as text and closing markers arrive", (marker, tag) => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
for (let length = 1; length <= 4; length++) {
|
||||
expect(parser.renderInline(marker + "text".slice(0, length))).toBe(
|
||||
`<${tag}>${"text".slice(0, length)}</${tag}>`,
|
||||
);
|
||||
}
|
||||
for (let length = 0; length <= marker.length; length++) {
|
||||
expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe(
|
||||
`<${tag}>text</${tag}>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps combined and nested emphasis stable through closing markers", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
for (const closing of ["", "*", "**", "***"]) {
|
||||
expect(parser.renderInline("***both" + closing)).toBe("<em><strong>both</strong></em>");
|
||||
}
|
||||
expect(parser.renderInline("**bold and *italic")).toBe(
|
||||
"<strong>bold and <em>italic</em></strong>",
|
||||
);
|
||||
expect(parser.renderInline("*italic and **bold")).toBe(
|
||||
"<em>italic and <strong>bold</strong></em>",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["*", "**", "***", "_", "__", "~~", "`", "``"])(
|
||||
"hides an opening %s while waiting for its text",
|
||||
(marker) => {
|
||||
expect(
|
||||
createAssistantMarkdownParser({ streaming: true }).renderInline("hello " + marker),
|
||||
).toBe("hello ");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"[docs](https://example.com/a(b)c)",
|
||||
'[docs](https://example.com "a title)")',
|
||||
"[docs](<https://example.com/a(b)>)",
|
||||
"[docs](file:///tmp/example.ts)",
|
||||
])("waits for the entire destination of %s", (source) => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
for (let length = 6; length < source.length; length++) {
|
||||
expect(parser.renderInline(source.slice(0, length))).toBe("docs");
|
||||
}
|
||||
expect(parser.renderInline(source)).toBe(createAssistantMarkdownParser().renderInline(source));
|
||||
});
|
||||
|
||||
it("preserves formatting in incomplete labels and around incomplete links", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
expect(parser.renderInline("[**bold")).toBe("<strong>bold</strong>");
|
||||
expect(parser.renderInline("[**bold**](https://exam")).toBe("<strong>bold</strong>");
|
||||
expect(parser.renderInline("**see [docs](https://exam")).toBe("<strong>see docs</strong>");
|
||||
});
|
||||
|
||||
it("does not auto-link a URL label before the destination is complete", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
expect(parser.renderInline("Read [example.com](https://exam")).toBe("Read example.com");
|
||||
expect(parser.renderInline("Read [example.com](https://example.org)")).toBe(
|
||||
'Read <a href="https://example.org">example.com</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"\\*literal",
|
||||
"\\[literal",
|
||||
"some_identifier",
|
||||
"some__identifier",
|
||||
"`**literal [link](url`",
|
||||
"``a ` b``",
|
||||
"**already closed** after",
|
||||
"[bad](javascript:alert(1))",
|
||||
"<component",
|
||||
"$$price",
|
||||
"20~25",
|
||||
])("preserves literal and complete inline text: %s", (source) => {
|
||||
expect(createAssistantMarkdownParser({ streaming: true }).renderInline(source)).toBe(
|
||||
createAssistantMarkdownParser().renderInline(source),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"```md\n**literal [link](url",
|
||||
"~~~md\n**literal [link](url",
|
||||
" **literal [link](url",
|
||||
"**earlier\n\nplain tail",
|
||||
"- **earlier\n- plain tail",
|
||||
])("leaves literal code and earlier blocks alone: %s", (source) => {
|
||||
expect(createAssistantMarkdownParser({ streaming: true }).render(source)).toBe(
|
||||
createAssistantMarkdownParser().render(source),
|
||||
);
|
||||
});
|
||||
|
||||
it("completes inline formatting inside the final list item", () => {
|
||||
expect(createAssistantMarkdownParser({ streaming: true }).render("- first\n- **bold")).toBe(
|
||||
"<ul>\n<li>first</li>\n<li><strong>bold</strong></li>\n</ul>\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides incomplete images until their source is complete", () => {
|
||||
const parser = createAssistantMarkdownParser({ streaming: true });
|
||||
expect(parser.renderInline("before ).toBe("before ");
|
||||
expect(parser.renderInline("before ")).toBe(
|
||||
'before <img src="https://example.com/image.png" alt="alt">',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps ordinary parsing for completed messages", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
expect(parser.renderInline("**unfinished")).toBe("**unfinished");
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
import { enableStreamingMarkdown } from "@/utils/streaming-markdown";
|
||||
|
||||
export function createAssistantMarkdownParser({ streaming = false } = {}): MarkdownIt {
|
||||
export function createAssistantMarkdownParser(): MarkdownIt {
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
|
||||
// Assistant messages are the only surface allowed to link into the
|
||||
// filesystem. Every other parser keeps markdown-it's stricter default.
|
||||
// Rich clipboard HTML preserves workspace file links.
|
||||
parser.validateLink = (url: string) =>
|
||||
url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url);
|
||||
|
||||
if (streaming) {
|
||||
enableStreamingMarkdown(parser);
|
||||
}
|
||||
|
||||
return parser;
|
||||
}
|
||||
|
||||
@@ -3,69 +3,30 @@ import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import {
|
||||
clearAssistantMessageHeightEstimateCache,
|
||||
estimateAssistantMessageHeightFromCache,
|
||||
setAssistantMarkdownBlockHeight,
|
||||
setAssistantMessageHeight,
|
||||
} from "./assistant-message-height-estimate";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
setAssistantImageMetadata,
|
||||
} from "./assistant-image-metadata";
|
||||
|
||||
describe("assistant message height estimate", () => {
|
||||
beforeEach(() => {
|
||||
clearAssistantMessageHeightEstimateCache();
|
||||
clearAssistantImageMetadataCache();
|
||||
});
|
||||
|
||||
it("estimates assistant message height from measured markdown block heights", () => {
|
||||
setAssistantMarkdownBlockHeight({
|
||||
block: "First paragraph",
|
||||
width: 804,
|
||||
height: 18.2,
|
||||
it("uses a whole-message measurement only at the measured width", () => {
|
||||
setAssistantMessageHeight({
|
||||
markdown: "First\n\nSecond",
|
||||
width: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
height: 97,
|
||||
});
|
||||
setAssistantMarkdownBlockHeight({
|
||||
block: "Second paragraph",
|
||||
width: 804,
|
||||
height: 41.1,
|
||||
});
|
||||
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "First paragraph\n\nSecond paragraph",
|
||||
markdown: "First\n\nSecond",
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBe(97);
|
||||
});
|
||||
|
||||
it("reads block heights measured at the configured content width", () => {
|
||||
setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 });
|
||||
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "Wide paragraph",
|
||||
markdown: "First\n\nSecond",
|
||||
contentMaxWidth: 1600,
|
||||
}),
|
||||
).toBe(44);
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "Wide paragraph",
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to image metadata when markdown blocks are not measured", () => {
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "https://example.com/landscape.png",
|
||||
},
|
||||
{ width: 1200, height: 800 },
|
||||
);
|
||||
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache({
|
||||
markdown: "Here is the screenshot\n\n",
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
}),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import {
|
||||
type AssistantMessageHeightEstimateInput,
|
||||
estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
const ASSISTANT_MESSAGE_HEIGHT_CACHE_LIMIT = 1000;
|
||||
|
||||
const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000;
|
||||
const ASSISTANT_MARKDOWN_BLOCK_INSET = 16;
|
||||
const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24;
|
||||
const ASSISTANT_MARKDOWN_BLOCK_GAP = 12;
|
||||
|
||||
interface MarkdownBlockHeightInput {
|
||||
block: string;
|
||||
interface MessageHeightInput {
|
||||
markdown: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const assistantMarkdownBlockHeightCache = new Map<string, number>();
|
||||
const assistantMessageHeightCache = new Map<string, number>();
|
||||
|
||||
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
|
||||
cache.delete(key);
|
||||
@@ -28,98 +19,67 @@ function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number
|
||||
}
|
||||
}
|
||||
|
||||
function hashMarkdownBlock(block: string): string {
|
||||
function hashMessage(markdown: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < block.length; index += 1) {
|
||||
hash ^= block.charCodeAt(index);
|
||||
for (let index = 0; index < markdown.length; index += 1) {
|
||||
hash ^= markdown.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `${block.length}:${(hash >>> 0).toString(36)}`;
|
||||
return `${markdown.length}:${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
function normalizeMarkdownBlockWidth(width: number): number | null {
|
||||
function normalizeMessageWidth(width: number): number | null {
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(width);
|
||||
}
|
||||
|
||||
function createMarkdownBlockHeightKey(input: MarkdownBlockHeightInput): string | null {
|
||||
const normalizedWidth = normalizeMarkdownBlockWidth(input.width);
|
||||
function createMessageHeightKey(input: MessageHeightInput): string | null {
|
||||
const normalizedWidth = normalizeMessageWidth(input.width);
|
||||
if (normalizedWidth === null) {
|
||||
return null;
|
||||
}
|
||||
if (input.block.length === 0) {
|
||||
if (input.markdown.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `${normalizedWidth}:${hashMarkdownBlock(input.block)}`;
|
||||
return `${normalizedWidth}:${hashMessage(input.markdown)}`;
|
||||
}
|
||||
|
||||
export function setAssistantMarkdownBlockHeight(input: {
|
||||
block: string;
|
||||
export function setAssistantMessageHeight(input: {
|
||||
markdown: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): number | null {
|
||||
if (!Number.isFinite(input.height) || input.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = createMarkdownBlockHeightKey({
|
||||
block: input.block,
|
||||
const key = createMessageHeightKey({
|
||||
markdown: input.markdown,
|
||||
width: input.width,
|
||||
});
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const height = Math.ceil(input.height);
|
||||
touchCacheEntry(
|
||||
assistantMarkdownBlockHeightCache,
|
||||
key,
|
||||
height,
|
||||
ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT,
|
||||
);
|
||||
touchCacheEntry(assistantMessageHeightCache, key, height, ASSISTANT_MESSAGE_HEIGHT_CACHE_LIMIT);
|
||||
return height;
|
||||
}
|
||||
|
||||
function estimateAssistantMarkdownBlockHeightFromCache(
|
||||
markdown: string,
|
||||
contentMaxWidth: number,
|
||||
): number | null {
|
||||
const blocks = splitMarkdownBlocks(markdown);
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let blockHeight = 0;
|
||||
for (const block of blocks) {
|
||||
const key = createMarkdownBlockHeightKey({
|
||||
block,
|
||||
width: contentMaxWidth - ASSISTANT_MARKDOWN_BLOCK_INSET,
|
||||
});
|
||||
const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined;
|
||||
if (cachedHeight === undefined) {
|
||||
return null;
|
||||
}
|
||||
blockHeight += cachedHeight;
|
||||
}
|
||||
|
||||
return (
|
||||
ASSISTANT_MESSAGE_VERTICAL_PADDING +
|
||||
blockHeight +
|
||||
ASSISTANT_MARKDOWN_BLOCK_GAP * Math.max(0, blocks.length - 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function estimateAssistantMessageHeightFromCache({
|
||||
markdown,
|
||||
contentMaxWidth,
|
||||
imageContext,
|
||||
}: AssistantMessageHeightEstimateInput): number | null {
|
||||
}: {
|
||||
markdown: string;
|
||||
contentMaxWidth: number;
|
||||
}): number | null {
|
||||
return (
|
||||
estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ??
|
||||
estimateAssistantImageMessageHeightFromCache({ markdown, contentMaxWidth, imageContext })
|
||||
assistantMessageHeightCache.get(
|
||||
createMessageHeightKey({ markdown: markdown, width: contentMaxWidth }) ?? "",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function clearAssistantMessageHeightEstimateCache(): void {
|
||||
assistantMarkdownBlockHeightCache.clear();
|
||||
assistantMessageHeightCache.clear();
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownNodeContainsType } from "./markdown-ast";
|
||||
|
||||
describe("markdownNodeContainsType", () => {
|
||||
it("matches the node itself", () => {
|
||||
expect(markdownNodeContainsType({ type: "image", children: [] }, "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches descendants", () => {
|
||||
const paragraph = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{ type: "text", children: [] },
|
||||
{
|
||||
type: "link",
|
||||
children: [{ type: "image", children: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(markdownNodeContainsType(paragraph, "image")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the type is absent", () => {
|
||||
const paragraph = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{ type: "text", children: [] },
|
||||
{ type: "strong", children: [{ type: "text", children: [] }] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(markdownNodeContainsType(paragraph, "image")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface MarkdownAstNodeWithChildren {
|
||||
type: string;
|
||||
children: MarkdownAstNodeWithChildren[];
|
||||
}
|
||||
|
||||
export function markdownNodeContainsType(node: MarkdownAstNodeWithChildren, type: string): boolean {
|
||||
if (node.type === type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return node.children.some((child) => markdownNodeContainsType(child, type));
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "./markdown-list";
|
||||
|
||||
describe("getMarkdownListMarker", () => {
|
||||
it("returns a bullet marker for unordered list items", () => {
|
||||
expect(getMarkdownListMarker({ index: 0 }, [{ type: "bullet_list" }])).toEqual({
|
||||
isOrdered: false,
|
||||
marker: "•",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns numbered markers for ordered list items", () => {
|
||||
expect(getMarkdownListMarker({ index: 1, markup: "." }, [{ type: "ordered_list" }])).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "2.",
|
||||
});
|
||||
});
|
||||
|
||||
it("respects ordered list start attribute", () => {
|
||||
expect(
|
||||
getMarkdownListMarker({ index: 2, markup: ")" }, [
|
||||
{ type: "ordered_list", attributes: { start: "5" } },
|
||||
]),
|
||||
).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "7)",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the nearest list ancestor in nested lists", () => {
|
||||
expect(
|
||||
getMarkdownListMarker({ index: 0, markup: "." }, [
|
||||
{ type: "ordered_list" },
|
||||
{ type: "bullet_list" },
|
||||
]),
|
||||
).toEqual({
|
||||
isOrdered: true,
|
||||
marker: "1.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMarkdownListSpacing", () => {
|
||||
it("keeps top-level list spacing as a section boundary", () => {
|
||||
const paragraph = { type: "paragraph" };
|
||||
const list = { type: "bullet_list" };
|
||||
const body = { type: "body", children: [list, paragraph] };
|
||||
|
||||
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||
marginTop: 4,
|
||||
marginBottom: 16,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add bottom spacing after a list at the end of a markdown block", () => {
|
||||
const list = { type: "bullet_list" };
|
||||
const body = { type: "body", children: [list] };
|
||||
|
||||
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||
marginTop: 4,
|
||||
marginBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a smaller gap between adjacent top-level lists", () => {
|
||||
const list = { type: "bullet_list" };
|
||||
const body = { type: "body", children: [list, { type: "ordered_list" }] };
|
||||
|
||||
expect(getMarkdownListSpacing(list, [body])).toEqual({
|
||||
marginTop: 4,
|
||||
marginBottom: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add section spacing after a nested list", () => {
|
||||
expect(
|
||||
getMarkdownListSpacing({ type: "bullet_list" }, [
|
||||
{ type: "list_item" },
|
||||
{ type: "bullet_list" },
|
||||
{ type: "body" },
|
||||
]),
|
||||
).toEqual({
|
||||
marginTop: 4,
|
||||
marginBottom: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
interface MarkdownNode {
|
||||
type?: string;
|
||||
index?: number;
|
||||
markup?: string;
|
||||
attributes?: {
|
||||
start?: number | string;
|
||||
};
|
||||
children?: MarkdownNode[];
|
||||
}
|
||||
|
||||
const LIST_BULLET = "•";
|
||||
const DEFAULT_ORDERED_LIST_MARKUP = ".";
|
||||
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1];
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4];
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2];
|
||||
const MARKDOWN_NESTED_LIST_MARGIN_BOTTOM = 0;
|
||||
const MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM = 0;
|
||||
|
||||
function toParentNodes(parent: unknown): MarkdownNode[] {
|
||||
if (Array.isArray(parent)) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
if (parent && typeof parent === "object") {
|
||||
return [parent as MarkdownNode];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getNearestListParent(parent: unknown): MarkdownNode | undefined {
|
||||
return toParentNodes(parent).find(
|
||||
(ancestor) => ancestor?.type === "ordered_list" || ancestor?.type === "bullet_list",
|
||||
);
|
||||
}
|
||||
|
||||
function getOrderedListItemIndex(node: MarkdownNode, listParent: MarkdownNode): number {
|
||||
if (typeof node.index === "number" && Number.isFinite(node.index) && node.index >= 0) {
|
||||
return node.index;
|
||||
}
|
||||
|
||||
if (Array.isArray(listParent.children)) {
|
||||
const fallbackIndex = listParent.children.indexOf(node);
|
||||
if (fallbackIndex >= 0) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseOrderedListStart(node: MarkdownNode): number {
|
||||
const rawStart = node.attributes?.start;
|
||||
if (typeof rawStart === "number" && Number.isFinite(rawStart)) {
|
||||
return rawStart;
|
||||
}
|
||||
|
||||
if (typeof rawStart === "string") {
|
||||
const parsed = Number.parseInt(rawStart, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getMarkdownNextSiblingType(
|
||||
node: MarkdownNode,
|
||||
parent: unknown,
|
||||
): string | undefined {
|
||||
const ancestors = toParentNodes(parent);
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const ancestor = ancestors[i];
|
||||
if (!Array.isArray(ancestor?.children)) continue;
|
||||
const idx = ancestor.children.indexOf(node);
|
||||
if (idx >= 0) {
|
||||
return ancestor.children[idx + 1]?.type;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isListType(type: string | undefined): boolean {
|
||||
return type === "bullet_list" || type === "ordered_list";
|
||||
}
|
||||
|
||||
function hasListItemAncestor(parent: unknown): boolean {
|
||||
return toParentNodes(parent).some((ancestor) => ancestor?.type === "list_item");
|
||||
}
|
||||
|
||||
export function getMarkdownListSpacing(
|
||||
node: MarkdownNode,
|
||||
parent: unknown,
|
||||
): { marginTop: number; marginBottom: number } {
|
||||
if (hasListItemAncestor(parent)) {
|
||||
return {
|
||||
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||
marginBottom: MARKDOWN_NESTED_LIST_MARGIN_BOTTOM,
|
||||
};
|
||||
}
|
||||
|
||||
const nextType = getMarkdownNextSiblingType(node, parent);
|
||||
if (!nextType) {
|
||||
return {
|
||||
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||
marginBottom: MARKDOWN_TERMINAL_LIST_MARGIN_BOTTOM,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
marginTop: MARKDOWN_LIST_MARGIN_TOP,
|
||||
marginBottom: isListType(nextType)
|
||||
? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST
|
||||
: MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMarkdownListMarker(
|
||||
node: MarkdownNode,
|
||||
parent: unknown,
|
||||
): {
|
||||
isOrdered: boolean;
|
||||
marker: string;
|
||||
} {
|
||||
const listParent = getNearestListParent(parent);
|
||||
if (!listParent || listParent.type !== "ordered_list") {
|
||||
return {
|
||||
isOrdered: false,
|
||||
marker: LIST_BULLET,
|
||||
};
|
||||
}
|
||||
|
||||
const orderedIndex = getOrderedListItemIndex(node, listParent);
|
||||
const orderedStart = parseOrderedListStart(listParent);
|
||||
const orderedMarkup =
|
||||
typeof node.markup === "string" && node.markup.length > 0
|
||||
? node.markup
|
||||
: DEFAULT_ORDERED_LIST_MARKUP;
|
||||
|
||||
return {
|
||||
isOrdered: true,
|
||||
marker: `${orderedStart + orderedIndex}${orderedMarkup}`,
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
// Only block maps are needed here; inline parsing belongs to each rendered block.
|
||||
const markdownBlockParser = new MarkdownIt();
|
||||
markdownBlockParser.core.ruler.disable("inline");
|
||||
|
||||
// The renderer decides what counts as a definition, so ask the same parser: a block
|
||||
// that produces no tokens but registers references is nothing but definitions.
|
||||
function isLinkReferenceDefinitionBlock(block: string): boolean {
|
||||
const env: { references?: Record<string, unknown> } = {};
|
||||
const tokens = markdownBlockParser.parse(block, env);
|
||||
return tokens.length === 0 && Object.keys(env.references ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definitions render nothing and resolve nothing on their own, so a block made only of
|
||||
* them would paint an empty row and break every reference that pointed at it. Fold it
|
||||
* into the block it belongs to: the one above, or the one below when it leads.
|
||||
*/
|
||||
function foldLinkReferenceDefinitions(blocks: string[]): string[] {
|
||||
const folded: string[] = [];
|
||||
let leading: string[] = [];
|
||||
for (const block of blocks) {
|
||||
if (isLinkReferenceDefinitionBlock(block)) {
|
||||
if (folded.length > 0) folded[folded.length - 1] += `\n\n${block}`;
|
||||
else leading.push(block);
|
||||
continue;
|
||||
}
|
||||
folded.push([...leading, block].join("\n\n"));
|
||||
leading = [];
|
||||
}
|
||||
if (leading.length > 0) folded.push(leading.join("\n\n"));
|
||||
return folded;
|
||||
}
|
||||
|
||||
export function splitMarkdownBlocks(text: string): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
let currentLines: string[] = [];
|
||||
let sawBlockSeparator = false;
|
||||
const lines = text.split("\n");
|
||||
const structuralBlankLines = getStructuralBlankLines(text, lines);
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const isBlankLine = line.trim().length === 0;
|
||||
|
||||
if (isBlankLine && structuralBlankLines.has(index)) {
|
||||
currentLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlankLine) {
|
||||
if (currentLines.length > 0) {
|
||||
sawBlockSeparator = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sawBlockSeparator) {
|
||||
blocks.push(currentLines.join("\n"));
|
||||
currentLines = [];
|
||||
sawBlockSeparator = false;
|
||||
}
|
||||
|
||||
currentLines.push(line);
|
||||
}
|
||||
|
||||
if (currentLines.length > 0) {
|
||||
blocks.push(currentLines.join("\n"));
|
||||
}
|
||||
|
||||
return foldLinkReferenceDefinitions(blocks.filter((block) => block.length > 0));
|
||||
}
|
||||
|
||||
function getStructuralBlankLines(text: string, lines: string[]): Set<number> {
|
||||
const blankLines = new Set<number>();
|
||||
for (const token of markdownBlockParser.parse(text, {})) {
|
||||
if (token.level !== 0 || !token.map) {
|
||||
continue;
|
||||
}
|
||||
const [start, end] = token.map;
|
||||
for (let index = start; index < end - 1; index += 1) {
|
||||
if (lines[index]?.trim().length === 0) {
|
||||
blankLines.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
return blankLines;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
|
||||
|
||||
const STREAMING_TAIL = Symbol("streaming markdown tail");
|
||||
|
||||
/** Adds provisional inline formatting without changing the source or literal code blocks. */
|
||||
export function enableStreamingMarkdown(parser: MarkdownIt): void {
|
||||
parser.core.ruler.at("inline", (state) => {
|
||||
const tail = state.tokens.findLast((token) => token.nesting !== -1);
|
||||
for (const token of state.tokens) {
|
||||
if (token.type === "inline") {
|
||||
const env = { ...state.env, [STREAMING_TAIL]: token === tail };
|
||||
token.children = [];
|
||||
state.md.inline.parse(token.content, state.md, env, token.children);
|
||||
}
|
||||
}
|
||||
});
|
||||
parser.inline.ruler.before("backticks", "streaming_code", completeCode);
|
||||
parser.inline.ruler.before("strikethrough", "streaming_marker", hideOpeningMarker);
|
||||
parser.inline.ruler.after("image", "streaming_link", completeLink);
|
||||
parser.inline.ruler2.after("balance_pairs", "streaming_emphasis", completeEmphasis);
|
||||
parser.core.ruler.after("linkify", "streaming_link_text", (state) => {
|
||||
for (const block of state.tokens) {
|
||||
for (const token of block.children ?? []) {
|
||||
if (token.type === "streaming_link_text") token.type = "text";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isStreamingTail(state: StateInline): boolean {
|
||||
return state.env[STREAMING_TAIL] === true && state.posMax === state.src.length;
|
||||
}
|
||||
|
||||
function hideOpeningMarker(state: StateInline, silent: boolean): boolean {
|
||||
if (silent || !isStreamingTail(state)) return false;
|
||||
if (!/^(?:\*{1,3}|_{1,3}|~{1,2})$/.test(state.src.slice(state.pos))) return false;
|
||||
if (state.scanDelims(state.pos, state.src[state.pos] !== "_").can_close) return false;
|
||||
state.pos = state.posMax;
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeCode(state: StateInline, silent: boolean): boolean {
|
||||
if (silent || !isStreamingTail(state) || state.src[state.pos] !== "`") return false;
|
||||
const runs = /`+/g;
|
||||
runs.lastIndex = state.pos;
|
||||
const opening = runs.exec(state.src)!;
|
||||
let closing: RegExpExecArray | null;
|
||||
while ((closing = runs.exec(state.src))) {
|
||||
if (closing[0].length === opening[0].length) return false;
|
||||
}
|
||||
let content = state.src.slice(state.pos + opening[0].length);
|
||||
// A shorter trailing run may be the first characters of the closing marker.
|
||||
const partialClosing = content.match(/`+$/);
|
||||
if (partialClosing && partialClosing[0].length < opening[0].length) {
|
||||
content = content.slice(0, -partialClosing[0].length);
|
||||
}
|
||||
if (content) {
|
||||
const token = state.push("code_inline", "code", 0);
|
||||
token.markup = opening[0];
|
||||
token.content = content.replace(/\n/g, " ").replace(/^ (.+) $/, "$1");
|
||||
}
|
||||
state.pos = state.posMax;
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeLink(state: StateInline, silent: boolean): boolean {
|
||||
if (silent || !isStreamingTail(state)) return false;
|
||||
const image = state.src[state.pos] === "!";
|
||||
const start = state.pos + (image ? 1 : 0);
|
||||
if (state.src[start] !== "[") return false;
|
||||
const labelEnd = state.md.helpers.parseLinkLabel(state, start, false);
|
||||
if (labelEnd !== -1 && labelEnd + 1 < state.posMax) {
|
||||
if (state.src[labelEnd + 1] !== "(" || hasClosedDestination(state.src, labelEnd + 1)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const end = state.posMax;
|
||||
if (!image) {
|
||||
if (state.pending) state.pushPending();
|
||||
const firstLabelToken = state.tokens.length;
|
||||
state.pos = start + 1;
|
||||
state.posMax = labelEnd === -1 ? end : labelEnd;
|
||||
state.level++;
|
||||
state.md.inline.tokenize(state);
|
||||
state.level--;
|
||||
// Keep URL-shaped labels out of the automatic linkifier until the real
|
||||
// destination arrives. Restore ordinary text before tokens reach the view.
|
||||
for (let index = firstLabelToken; index < state.tokens.length; index++) {
|
||||
if (state.tokens[index].type === "text") state.tokens[index].type = "streaming_link_text";
|
||||
}
|
||||
}
|
||||
state.pos = state.posMax = end;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasClosedDestination(source: string, start: number): boolean {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = start; index < source.length; index++) {
|
||||
const char = source[index];
|
||||
if (char === "\\") {
|
||||
index++;
|
||||
} else if (quote) {
|
||||
if (char === quote) quote = "";
|
||||
} else if (char === "<") {
|
||||
quote = ">";
|
||||
} else if ((char === '"' || char === "'") && /\s/.test(source[index - 1])) {
|
||||
quote = char;
|
||||
} else if (char === "(") {
|
||||
depth++;
|
||||
} else if (char === ")" && --depth === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function completeEmphasis(state: StateInline): boolean {
|
||||
if (!isStreamingTail(state)) return false;
|
||||
const open = [];
|
||||
let closedThrough = -1;
|
||||
// Reuse markdown-it's escape, word-boundary, nesting and delimiter matching
|
||||
// decisions. Only unmatched openers outside already-closed spans reach EOF.
|
||||
for (const [index, delimiter] of state.delimiters.entries()) {
|
||||
if (delimiter.end !== -1) {
|
||||
closedThrough = Math.max(closedThrough, delimiter.end);
|
||||
} else if (index > closedThrough && delimiter.open) {
|
||||
open.push(delimiter);
|
||||
}
|
||||
}
|
||||
for (const delimiter of open.toReversed()) {
|
||||
if (delimiter.marker === 126 && /(?<![\\~])~$/.test(state.src)) {
|
||||
const last = state.tokens.at(-1);
|
||||
if (last?.type === "text" && last.content.endsWith("~")) {
|
||||
last.content = last.content.slice(0, -1);
|
||||
}
|
||||
}
|
||||
const closing = state.push("text", "", 0);
|
||||
closing.content = String.fromCharCode(delimiter.marker).repeat(
|
||||
delimiter.marker === 126 ? 2 : 1,
|
||||
);
|
||||
delimiter.end = state.delimiters.length;
|
||||
state.delimiters.push({
|
||||
...delimiter,
|
||||
token: state.tokens.length - 1,
|
||||
end: -1,
|
||||
open: false,
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user