summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/markdown/ui
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/markdown/ui')
-rw-r--r--src/features/markdown/ui/Markdown.svelte44
-rw-r--r--src/features/markdown/ui/markdown.test.ts58
2 files changed, 97 insertions, 5 deletions
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index da2c9ba..a0fd276 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,16 +1,30 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/markdown";
+ import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
let {
text,
streaming = false,
+ image = undefined,
}: {
text: string;
/** Balance dangling delimiters while the message is still generating. */
streaming?: boolean;
+ /**
+ * Per-conversation local-image context: the backend HTTP base and the
+ * conversation cwd. Markdown image srcs that are local paths are rewritten
+ * through `${apiBase}/local-image?path=…` (relative paths resolve against
+ * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
+ * backend images load inline. Omit it to skip local-path rewriting.
+ */
+ image?: MarkdownImageOptions;
} = $props();
+ // Keep the optional `image` out of the options object when absent
+ // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
+ // keys on apiBase/cwd values.
+ const renderOpts = $derived(image === undefined ? undefined : { image });
+
// Checkpoint streaming state: one per component instance (per streaming
// message). It caches the settled blocks' HTML so each delta re-renders only
// the live tail — never the settled prefix (see logic/streaming-markdown).
@@ -22,14 +36,14 @@
// chunk): ONE full authoritative render — today's exact behavior, so the
// final/persisted output is byte-identical by construction.
const rendered = $derived.by(() => {
- if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text) };
- return advance(streamingState, text);
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
+ return advance(streamingState, text, renderOpts);
});
let container: HTMLElement;
- // One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when the tail html changes
+ // One delegated listener on the stable container handles image consent and
+ // every code block's copy button — including blocks re-created when the tail html changes
// (streaming), since the listener lives on the container, not the buttons.
// Clipboard is the edge effect; absent (insecure context) → no-op.
$effect(() => {
@@ -39,6 +53,26 @@
const onClick = (event: Event): void => {
const target = event.target;
if (!(target instanceof Element)) return;
+ const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
+ if (remote !== null && el.contains(remote)) {
+ event.preventDefault(); // A Markdown image may be wrapped in a link.
+ event.stopPropagation();
+ // data-* is authorable in raw HTML: validate again at the DOM edge.
+ let url: URL;
+ try {
+ url = new URL(remote.dataset.src ?? "");
+ } catch {
+ return;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
+ const img = el.ownerDocument.createElement("img");
+ img.alt = remote.dataset.alt ?? "";
+ if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
+ img.setAttribute("referrerpolicy", "no-referrer");
+ img.src = url.href;
+ remote.replaceWith(img);
+ return;
+ }
const button = target.closest<HTMLButtonElement>("[data-copy]");
if (button === null) return;
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts
index 721dddb..b86cc0a 100644
--- a/src/features/markdown/ui/markdown.test.ts
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -39,6 +39,64 @@ describe("Markdown", () => {
expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
});
+ it.each([
+ false,
+ true,
+ ])("loads only the clicked remote image (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '![first](https://evil.example/a.png "A title")\n\n![second](https://other.example/b.png)',
+ streaming,
+ },
+ });
+ expect(container.querySelector("img")).toBeNull();
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ await fireEvent.click(button);
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toBe("https://evil.example/a.png");
+ expect(img?.getAttribute("alt")).toBe("first");
+ expect(img?.getAttribute("title")).toBe("A title");
+ expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer");
+ expect(
+ screen.getByRole("button", { name: "Load image from other.example" }),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>',
+ streaming,
+ },
+ });
+ expect(container.querySelector("video,svg,input,[style],img")).toBeNull();
+ await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" }));
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "https://evil.example/allowed-after-click",
+ );
+ expect(container.querySelector("video,svg,input,[style]")).toBeNull();
+ });
+
+ it("rejects an unsafe URL in a forged placeholder", async () => {
+ const { container } = render(Markdown, {
+ props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' },
+ });
+ await fireEvent.click(screen.getByRole("button", { name: "Load" }));
+ expect(container.querySelector("img")).toBeNull();
+ });
+
+ it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => {
+ const { container, rerender } = render(Markdown, { props: { text: "start" } });
+ await rerender({ text: "[![pic](https://evil.example/a.png)](https://other.example)" });
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ expect(await fireEvent.click(button)).toBe(false);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png");
+ });
+
it("non-streaming renders a single container (today's full-render path)", () => {
const { container } = render(Markdown, { props: { text: "# Hello" } });
expect(container.querySelector(".markdown-stream-prefix")).toBeNull();