diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 18:48:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 18:48:54 +0900 |
| commit | 34526ff48985643bed532b102e8943fa69e77a79 (patch) | |
| tree | 4208ec0f5f1ef2cf1a9cd9710a442ce10a5f0c61 /src/features/markdown/ui | |
| parent | daefad66bdf8dee132d701a3b43ce0d227dde241 (diff) | |
| parent | b666d77df8cf4da6ae40fb5637268926f02cc44d (diff) | |
| download | dispatch-web-34526ff48985643bed532b102e8943fa69e77a79.tar.gz dispatch-web-34526ff48985643bed532b102e8943fa69e77a79.zip | |
Merge branch 'feature/text-file-embeds' into dev
Diffstat (limited to 'src/features/markdown/ui')
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 125 | ||||
| -rw-r--r-- | src/features/markdown/ui/MarkdownContent.svelte | 116 | ||||
| -rw-r--r-- | src/features/markdown/ui/TextFileEmbed.svelte | 113 | ||||
| -rw-r--r-- | src/features/markdown/ui/text-file.test.ts | 190 |
4 files changed, 440 insertions, 104 deletions
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index a0fd276..c33f9f6 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,116 +1,33 @@ <script lang="ts"> - import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown"; - import { advance, createStreamingMarkdown } from "../logic/streaming-markdown"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { splitTextFiles } from "../logic/text-file"; + import type { TextFileClient } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + import TextFileEmbed from "./TextFileEmbed.svelte"; let { text, streaming = false, image = undefined, + textFiles = 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; + image?: MarkdownImageOptions | undefined; + /** Explicit opt-in; embedded documents never receive this port. */ + textFiles?: TextFileClient | undefined; } = $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). - const streamingState = createStreamingMarkdown(); - - // While streaming: the checkpoint split (settled prefix + live tail), which - // equals today's full render at every step modulo inter-block newlines and - // highlight-on-close. When the message completes (or for every non-streaming - // 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, renderOpts) }; - return advance(streamingState, text, renderOpts); - }); - - let container: HTMLElement; - - // 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(() => { - const el = container; - if (el === undefined) return; - - 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; - - const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - - void clipboard - .writeText(code) - .then(() => { - const prev = button.textContent; - button.textContent = "Copied"; - setTimeout(() => { - button.textContent = prev; - }, 1200); - }) - .catch(() => { - // Clipboard denied — leave the button as-is. - }); - }; - - el.addEventListener("click", onClick); - return () => el.removeEventListener("click", onClick); - }); + const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null); </script> -<div class="markdown-body" bind:this={container}> - {#if streaming} - <!-- Two sibling wrappers (no padding/border/min-height, so block margins - collapse across them exactly as if the blocks were direct children). - Svelte 5 skips the {@html} DOM write when the string is unchanged, so - the settled prefix's subtree is never re-parsed/re-created/re-laid-out; - only the tail's subtree is replaced per delta. Both strings are - DOMPurify-sanitized in the logic modules. --> - <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div> - <div class="markdown-stream-tail">{@html rendered.tailHtml}</div> - {:else} - <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> - {@html rendered.tailHtml} - {/if} -</div> +{#if parts && textFiles} + {#each parts as part, i (i)} + {#if part.kind === "text-file"} + <TextFileEmbed reference={part.reference} client={textFiles} {image} /> + {:else} + <MarkdownContent text={part.text} {streaming} {image} /> + {/if} + {/each} +{:else} + <MarkdownContent {text} {streaming} {image} /> +{/if} diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte new file mode 100644 index 0000000..6149d6b --- /dev/null +++ b/src/features/markdown/ui/MarkdownContent.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + 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 | undefined; + } = $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). + const streamingState = createStreamingMarkdown(); + + // While streaming: the checkpoint split (settled prefix + live tail), which + // equals today's full render at every step modulo inter-block newlines and + // highlight-on-close. When the message completes (or for every non-streaming + // 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, renderOpts) }; + return advance(streamingState, text, renderOpts); + }); + + let container: HTMLElement; + + // 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(() => { + const el = container; + if (el === undefined) return; + + 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; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + {#if streaming} + <!-- Two sibling wrappers (no padding/border/min-height, so block margins + collapse across them exactly as if the blocks were direct children). + Svelte 5 skips the {@html} DOM write when the string is unchanged, so + the settled prefix's subtree is never re-parsed/re-created/re-laid-out; + only the tail's subtree is replaced per delta. Both strings are + DOMPurify-sanitized in the logic modules. --> + <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div> + <div class="markdown-stream-tail">{@html rendered.tailHtml}</div> + {:else} + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html rendered.tailHtml} + {/if} +</div> diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte new file mode 100644 index 0000000..2e1a7a3 --- /dev/null +++ b/src/features/markdown/ui/TextFileEmbed.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { textFilePreview, type TextFileReference } from "../logic/text-file"; + import type { TextFileClient, TextFileState } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + + let { + reference, + client, + image = undefined, + }: { + reference: TextFileReference; + client: TextFileClient; + image?: MarkdownImageOptions | undefined; + } = $props(); + let loadState = $state<TextFileState>({ kind: "loading" }); + let expanded = $state(false); + const preview = $derived( + loadState.kind === "ready" + ? textFilePreview(loadState.file.text, loadState.file.format === "markdown") + : null, + ); + const path = $derived(reference.path); + const currentClient = $derived(client); + $effect(() => { + const source = currentClient; + const destination = path; + expanded = false; + // The callback can publish synchronously. Only routing changes, not reads + // inside a subscriber or unrelated parent prop updates, own this effect. + return untrack(() => + source.subscribe(destination, (next) => { + loadState = next; + }), + ); + }); + // Use the resolved file's directory for local images, not the chat's cwd. + const documentImage = $derived( + loadState.kind === "ready" && image + ? { + apiBase: image.apiBase, + cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/", + } + : image, + ); +</script> + +<section + class="inset-well my-2 w-full min-w-0 rounded-box text-sm" + aria-label={`Text file: ${reference.label}`} +> + <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium"> + <span aria-hidden="true">▤</span> + <span>{reference.label}</span> + <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60" + >{reference.path}</span + > + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => client.refresh(reference.path)} + disabled={loadState.kind === "loading"} + > + {loadState.kind === "error" ? "Retry" : "Refresh"} + </button> + </header> + <div class="min-w-0 px-4 pb-4"> + {#if loadState.kind === "loading"} + <p role="status"> + <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file… + </p> + {:else if loadState.kind === "error"} + <p role="alert" class="text-error">{loadState.message}</p> + {:else if loadState.file.text === ""} + <p class="italic text-base-content/60">Empty file</p> + {:else if preview} + {#if expanded} + {@render content(loadState.file.text, loadState.file.format)} + {:else} + {@render content(preview.head, loadState.file.format)} + {#if preview.omitted > 0} + <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60"> + ⋯ {preview.omitted} source lines omitted ⋯ + </p> + {@render content(preview.tail, loadState.file.format)} + {/if} + {/if} + <button + type="button" + class="btn btn-soft btn-xs mt-2" + aria-expanded={expanded} + onclick={() => { + expanded = !expanded; + }} + > + {expanded ? "Collapse document" : "Expand document"} + </button> + <p class="mt-2 text-xs text-base-content/50"> + Live file · Refresh rereads disk · maximum 256 KiB + </p> + {/if} + </div> +</section> + +{#snippet content(text: string, format: "markdown" | "text")} + {#if format === "markdown"} + <!-- No textFiles port: nested references are inert, even in expanded content. --> + <MarkdownContent {text} image={documentImage} /> + {:else} + <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre> + {/if} +{/snippet} diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts new file mode 100644 index 0000000..cbbf11e --- /dev/null +++ b/src/features/markdown/ui/text-file.test.ts @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import { createTextFileClient } from "../adapter/text-file"; +import Markdown from "./Markdown.svelte"; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +function fixture(text: string, format: "markdown" | "text" = "markdown") { + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => + json( + String(url).endsWith("-context") + ? context + : { + path: "/project/notes/plan.md", + text, + format, + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, + }, + ), + ); + return { fetcher, client: createTextFileClient("/api", "c", fetcher) }; +} +describe("rendered live text-file embeds", () => { + it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => { + const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n"); + const { client, fetcher } = fixture(text); + const { unmount } = render(Markdown, { + text: "Summary\n\n[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading text file"); + await screen.findByRole("heading", { name: "Line 1" }); + expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await fireEvent.click(screen.getByRole("button", { name: "Collapse document" })); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => { + const text = + '# Short\n\n[nested](text-file:secret.md)\n\n<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\n\n[bad](javascript:alert(1))'; + const { client, fetcher } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Short" }); + expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1); + expect( + container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"), + ).toBeNull(); + expect(container.textContent).toContain("nested"); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("does not render omitted images before expansion; restores tail fence context", async () => { + const text = + "# Head\n\n1\n2\n3\n4\n\n```js\n" + + Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") + + "\n```"; + const { client } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Head" }); + expect( + screen.queryByRole("button", { name: "Load image from hidden.example" }), + ).not.toBeInTheDocument(); + expect(container.querySelector("code.language-js")).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect( + screen.getByRole("button", { name: "Load image from hidden.example" }), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + unmount(); + client.dispose(); + }); + it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => { + const { client } = fixture("# heading\n <script>bad</script>\n\tlast", "text"); + const { container, unmount } = render(Markdown, { + text: "[Text](text-file:a.txt)", + textFiles: client, + }); + await waitFor(() => + expect(container.querySelector("pre code")?.textContent).toBe( + "# heading\n <script>bad</script>\n\tlast", + ), + ); + expect(container.querySelector("script,h1")).toBeNull(); + unmount(); + client.dispose(); + }); + it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => { + const { client, fetcher } = fixture(""); + fetcher + .mockResolvedValueOnce(json(context)) + .mockResolvedValueOnce(json({ error: "Missing file" }, 404)); + const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client }); + expect(await screen.findByRole("alert")).toHaveTextContent("Missing file"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Empty file"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText("Empty file"); + expect(fetcher).toHaveBeenCalledTimes(6); + unmount(); + client.dispose(); + }); + it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => { + const { client, fetcher } = fixture("body"); + const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" }); + await rerender({ + text: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```', + textFiles: client, + }); + await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client }); + expect(fetcher).not.toHaveBeenCalled(); + await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client }); + await screen.findByText("body"); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: true, + textFiles: client, + }); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: false, + textFiles: client, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("ignores old conversation results after the context-bound client changes", async () => { + let finish: (r: Response) => void = () => {}; + const old = fixture("old"); + old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const next = fixture("new"); + const { rerender, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: old.client, + }); + await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2)); + await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client }); + await screen.findByText("new"); + finish( + json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }), + ); + await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument()); + expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + unmount(); + old.client.dispose(); + next.client.dispose(); + }); + it("deduplicates repeated blocks without replacing them on per-token appends", async () => { + const { client, fetcher } = fixture("# shared"); + const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n"; + const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client }); + await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement, + ); + await rerender({ text: `${text}growing`, streaming: true, textFiles: client }); + expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); +}); |
