diff options
| author | Adam Malczewski <[email protected]> | 2026-10-24 18:34:12 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-24 18:34:12 +0900 |
| commit | 48cdc3d19a319981a8a4128be98db3c283f81790 (patch) | |
| tree | 0fa6531f3b05c81348f9d3427e9021dbb4832b5d | |
| parent | daefad66bdf8dee132d701a3b43ce0d227dde241 (diff) | |
| download | dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.tar.gz dispatch-web-48cdc3d19a319981a8a4128be98db3c283f81790.zip | |
feat: render live text-file embeds in expandable Markdown blocks
Show rendered first/last six-line previews with expand, refresh and retry; bound and deduplicate context-guarded fetches. Verify 2586 tests twice with Node Web Storage compatibility flag, 2 live integration tests, typecheck, Biome and production build.
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 76 | ||||
| -rw-r--r-- | backend-handoff.md | 29 | ||||
| -rw-r--r-- | notes/text-file-embeds-implementation.md | 236 | ||||
| -rw-r--r-- | notes/text-file-embeds-plan.md | 93 | ||||
| -rw-r--r-- | scripts/text-file-live.test.ts | 133 | ||||
| -rw-r--r-- | src/app/conversation-settings.svelte.ts | 16 | ||||
| -rw-r--r-- | src/app/store-types.ts | 4 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 7 | ||||
| -rw-r--r-- | src/app/views/ChatPane.svelte | 11 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 6 | ||||
| -rw-r--r-- | src/features/markdown/adapter/text-file.test.ts | 168 | ||||
| -rw-r--r-- | src/features/markdown/adapter/text-file.ts | 171 | ||||
| -rw-r--r-- | src/features/markdown/index.ts | 3 | ||||
| -rw-r--r-- | src/features/markdown/logic/text-file.test.ts | 117 | ||||
| -rw-r--r-- | src/features/markdown/logic/text-file.ts | 116 | ||||
| -rw-r--r-- | src/features/markdown/types.ts | 13 | ||||
| -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 |
20 files changed, 1633 insertions, 110 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index e0ef475..eaa09a2 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,9 +1,10 @@ # `@dispatch/transport-contract` — in-repo reference -Mirrored from the sibling backend on 2026-10-21 for `feature/subscription-delivery-fix`. +Mirrored from the authorized public transport contract on 2026-10-24 for `feature/text-file-embeds`. Package version remains 0.26.0; local `file:` dependency unchanged. -New additive messages: `chat.accepted` after opening-message storage or queue insertion, +Additive text-file seam: `TextFileQuery`, `TextFileResponse`, and `TextFileContextResponse` +for guarded, no-store local text display. Existing additive messages: `chat.accepted` after opening-message storage or queue insertion, `chat.subscription-rejected` for a nonfatal watcher limit, and `requestId` on queue requests and chat errors. Correlation tokens are not idempotency keys; clients must not automatically retry uncertain sends. The server retains the 256 combined subscription bound. @@ -373,6 +374,76 @@ export interface ThroughputResponse { readonly models: readonly ThroughputModelStat[]; } +// ─── Live conversation text files ──────────────────────────────────────────── + +/** + * Response body for `GET /conversations/:id/text-file-context` — the effective, + * authoritative conversation/workspace context used by the text-file endpoint, + * not merely the conversation's persisted cwd/computer overrides. + * + * This minimal preflight shares context resolution with the file read, without + * starting/querying LSP or reading any files. An unknown conversation → HTTP 404. + * It is live context, not a snapshot; responses use `Cache-Control: no-store`. + * Clients should load it before requesting a text file and supply BOTH returned + * values as guards in `TextFileQuery` (encode null as the empty string). + */ +export interface TextFileContextResponse { + readonly conversationId: string; + /** Effective authoritative cwd, or null when unset (no process-cwd fallback). */ + readonly cwd: string | null; + /** Effective authoritative computer target, or null for local execution. */ + readonly computerId: string | null; +} + +/** + * Query parameters for `GET /conversations/:id/text-file`. + * + * Pass the RAW literal path to `URLSearchParams`; only the URL query decoder + * decodes it. The server MUST NOT percent-decode the resulting path a second + * time (a literal `%20` in a filename stays `%20`, not a space). + * + * Resolution uses the authoritative conversation/workspace context, never a + * client-supplied context override. A relative path requires an authoritative + * cwd; there is no process-cwd fallback. Only local regular files are supported, + * not ordinary network/protocol fetching. A remote computer target → HTTP 422. + * + * Guards compare against the effective context used for this read. An omitted + * guard skips that comparison; an empty string asserts null (cwd unset or + * computer local). A mismatch → HTTP 409. Browsers should supply BOTH guards + * after loading `TextFileContextResponse` to detect context changes. + */ +export interface TextFileQuery { + /** Literal absolute or cwd-relative POSIX file path, not a URL to fetch. */ + readonly path: string; + /** Expected effective cwd; empty string asserts unset, omitted skips guard. */ + readonly expectedCwd?: string; + /** Expected effective computer; empty string asserts local, omitted skips guard. */ + readonly expectedComputerId?: string; +} + +/** + * Response body for `GET /conversations/:id/text-file` — a live file read, NOT a + * persisted snapshot or attachment. Responses use `Cache-Control: no-store`. + * + * Only regular files containing strict UTF-8 without NUL or binary control + * characters are accepted. Reads are bounded to 256 KiB (262144 bytes) under + * the current policy, with no truncation. Invalid paths, nonregular files, or + * nontext content → HTTP 400; unknown conversations or missing files → HTTP + * 404; context-guard mismatch → HTTP 409; oversized files → HTTP 413; remote + * computer targets (unsupported) → HTTP 422. + */ +export interface TextFileResponse { + /** Resolved absolute POSIX file path. */ + readonly path: string; + /** Complete decoded UTF-8 file content, never truncated. */ + readonly text: string; + readonly format: "markdown" | "text"; + /** Original UTF-8 file size in bytes (not JavaScript string length). */ + readonly bytes: number; + /** Maximum accepted file size in bytes; currently 262144 (256 KiB). */ + readonly maxBytes: number; +} + // ─── Per-conversation working directory (cwd) ───────────────────────────────── /** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ @@ -1878,5 +1949,4 @@ export interface AccountUsageResponse { /** Server clock (epoch-ms) when this response was built — use it to compute window progress. */ readonly servedAt: number; } - ``` diff --git a/backend-handoff.md b/backend-handoff.md index e6da7b9..acae2f6 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -648,3 +648,32 @@ changes, NO contract changes — the wire types and every endpoint consumed are toggle default/reversibility/caching/fallback/mimeType); biome clean; `build` succeeds. Manual browser sanity (paste a ~3MB PNG → staged size drops; uncheck → original; re-check → no visible re-encode; send works) still needs a human at the page. + +## 2026-10-24 — feature/text-file-embeds + +Implemented saved text-file chat embeds using standalone `[Plan](text-file:notes/plan.md)`. +Markdown renders inside a tool-call-style inset well with first/last six-source-line +preview, Expand/Collapse, Refresh/Retry, loading/empty/error states. Other text/code +files remain literal. Backend adds authoritative `/text-file-context` preflight and +bounded `/text-file` read routes; `TextFileQuery`, `TextFileResponse`, and +`TextFileContextResponse` are additive transport-contract0.26.0 exports mirrored in +`.dispatch/transport-contract.reference.md` (local file dependency unchanged). +Both guards are supplied; remote computer never falls back to local. + +256KiB strict UTF-8, regular local files only, no truncation/final symlinks, live +current-disk views (not snapshots/watchers), no history injection/nested fetches. +Cache per mounted chat:32 active paths/4 loads; max8 embeds/message. Existing +Host/Origin trusted-network boundary retained (not new bearer/session auth). +Initial guidance reaches custom/default newly constructed prompts; persisted old +prompts unchanged until ordinary reconstruction. Warm parity verified. Explicit +per-turn systemPrompt bypass preserved. + +Verification: typecheck0 errors/0warnings; Biome clean308 files; Vite build succeeds; +2586 tests pass twice with `NODE_OPTIONS=--no-experimental-webstorage`; two real +cross-repo ephemeral HTTP/Svelte integration tests pass opt-in. Plain Node26 test +run has26 baseline localStorage failures independently reproduced on unmodified +HEAD archive. Backend4277 Vitest/190 Bun green, typecheck/lint/standalone compile +green; standard external-Claude bundle has existing missing @dispatch/kernel issue. +No merge/push/deployment/shared restart. Full report: +`notes/text-file-embeds-implementation.md`. Human graphical desktop/mobile check +remains; no cross-repo contract asks open for this slice. diff --git a/notes/text-file-embeds-implementation.md b/notes/text-file-embeds-implementation.md new file mode 100644 index 0000000..349f67e --- /dev/null +++ b/notes/text-file-embeds-implementation.md @@ -0,0 +1,236 @@ +# Text-file embeds — implementation report + +## Delivered behavior and syntax + +Save the document completely, then emit a short summary and a **standalone paragraph**: + +```markdown +I saved the implementation plan; the key change is the new validation boundary. + +[Implementation plan](text-file:notes/plan.md) +``` + +Absolute path and filename encoding examples: + +```markdown +[Research](text-file:/tmp/research.md) + +[Research with spaces](<text-file:notes/research%20report.md>) + +[Literal percent](text-file:notes/100%25.md) +``` + +The prefix is exactly `text-file:` (not image syntax). Destinations are POSIX +absolute paths or paths relative to the conversation's authoritative effective +working directory. Angle-bracket destinations can also contain literal spaces. +Markdown escapes are interpreted by marked, then percent escapes are decoded +**once**. `%2520` denotes a literal `%20` filename; `%23` and `%3F` denote filename +`#` and `?`. The HTTP adapter uses URLSearchParams to transport that literal +filesystem path; the backend does not perform another destination decode. + +Only a complete, single-line, top-level inline-link paragraph opts in. Ordinary +links, images, inline/fenced/indented code, reference-style links, titled links, +list/blockquote links, and links surrounded by prose retain ordinary Markdown +behavior and never trigger text-file reads. While streaming, an embed waits for +a blank-line paragraph boundary or completion of the message, avoiding speculative +fetches for incomplete links. Examples inside code never fetch. Unsupported or +malformed destinations remain inert ordinary Markdown, not browser/server URL +fetches. Raw HTML cannot forge a file-fetching block. + +## Presentation + +The document appears inside the existing **inset-well**, matching the recessed +panel style of tool-call/thinking blocks. It includes its label/path and +Refresh/Retry controls. Markdown extensions `.md`, `.markdown`, `.mdown`, `.mkd`, +`.mkdn` render through the existing marked/highlight.js/DOMPurify pipeline. +Other valid text/code files render safely as literal whitespace-preserving code, +without guessing a language or interpreting their HTML/Markdown. + +The default preview renders approximately the **first six and last six source +lines**, with an omitted-source-line count. Files of twelve lines or fewer are +not duplicated. Expand document shows the full rendered document; Collapse +document returns to the preview. The control exposes `aria-expanded`. +Top-level code-fence context is restored around preview fragments. Previews are +source-line excerpts, not a six-visual-line layout clamp: wrapping, headings, +tables and multiline constructs can change their visual height/formatting. Some +constructs crossing excerpt boundaries (lists, tables, reference definitions, +raw HTML) are necessarily approximate; expansion renders the authoritative full +Markdown. + +Both preview and full content are sanitized. Existing raw-HTML/link policy and +remote-image click consent are preserved. Omitted images are not mounted until +expansion. Relative local images resolve against the returned document's directory +through the existing local-image route. Embedded documents receive no text-file +client, so nested embed references are inert and cannot recursively fetch. + +Loading, empty-file, transport/validation failure and retry states are visible; +failures do not break surrounding chat. File text is display data, never a +conversation/history mutation. + +## File lifecycle and resource policy + +- **Live view, not snapshot.** Only the short reference remains in stored chat. + No document body or hash/snapshot is persisted with the message. +- A mounted chat fetches current disk bytes on first reference. Repeated exact + decoded paths share an in-flight/result entry while subscribed. There is no + file watcher or polling. Already displayed content does not update itself. +- Refresh (or Retry after failure) invalidates **all live files in that mounted + chat**, refetches authoritative context, and rereads their current contents. + This prevents mixing old and refreshed routing contexts. Expansion alone does + not fetch. File deletion produces a not-found state on the next read; recreation + can recover through Retry. +- When the last subscriber leaves, its entry is aborted/released. Returning to + chat/reloading/remounting reads current disk state, which can differ from the + original message. No historical immutability is implied. +- Known cwd/computer setting changes and conversation/workspace switches replace + the context-bound client, abort old requests and discard its cache. Late + responses cannot publish into a new client/path. External context edits not + signaled to the UI are detected by backend guards on subsequent reads or by + Refresh; existing displayed text is not watched for external changes. +- Limit **256 KiB (262144 bytes)** per file, strict UTF-8, allowing tab/LF/CR but + rejecting NUL and other C0/C1 binary controls. Exactly the limit is accepted; + oversize files are rejected, never silently truncated. +- At most **8 embeds per message**, **32 active distinct paths per mounted chat** + (at most 8 MiB source text), and **4 concurrent file loads**. The per-message + limit is explained inline; active-cache overflow has a visible error. +- Backend opens a single nonblocking/no-follow descriptor, checks regular-file + status and reads at most limit+1 bytes, including a final growth check. FIFOs, + devices, directories and final-component symlinks are rejected. Ancestor + directory symlinks and ordinary `..`/absolute paths are allowed: this is **not a + cwd sandbox**. Reads have a byte/work bound but no special filesystem timeout + or guarantee of a coherent snapshot while another process modifies a file. + +Unsupported: file URLs, HTTP/network destinations, tilde expansion, Windows paths, +backslashes, control characters and remote-computer embeds. Remote targets are +explicitly rejected, never read from an identically named local path. + +## Backend seam and trust boundary + +Existing `transport-http` is extended; no new extension/tier or kernel feature. +Additive `transport-contract` interfaces are `TextFileQuery`, `TextFileResponse`, +`TextFileContextResponse`; package version remains 0.26.0 with local `file:` deps, +and the frontend's tracked public contract mirror is updated. + +```text +GET /conversations/:id/text-file-context + -> { conversationId, cwd: string|null, computerId: string|null } + +GET /conversations/:id/text-file?path=...&expectedCwd=...&expectedComputerId=... + -> { path, text, format: "markdown"|"text", bytes, maxBytes } +``` + +Context preflight performs no LSP or file work. Both routes resolve persisted +conversation settings and workspace defaults server-side, not browser state or +server process cwd. Relative conversation cwd is resolved against an absolute +workspace default; an unresolvable configured relative cwd returns 422. With no +cwd configured, context is null, absolute local file references work, and relative +file references return 400. The frontend always supplies both preflight-derived +guards. Empty guard values assert unset cwd/local computer; absent guards skip +comparison. Guards never override server routing. The route rechecks context +after I/O before returning successful text. + +All endpoint responses use `Cache-Control: no-store`, including middleware errors. +Relevant statuses: 400 invalid/non-text/nonregular, 403 unreadable or rejected +Origin, 404 unknown conversation/missing file, 409 stale context, 413 oversized, +422 unsupported remote/unresolvable configured context, 500 read failure. Existing +Host rejection remains 421. + +**Authentication limitation, explicitly audited:** this application currently uses +Host/Origin checks plus loopback/Tailscale deployment restrictions, not bearer or +per-user session authentication. The new routes retain exactly that existing trust +boundary; they do not invent an authentication guarantee. A trusted client with +access to this API can read supported local files accessible to the backend UID, +including absolute paths outside cwd. Do not expose this transport publicly or to +untrusted users. No arbitrary HTTP fetch is added. + +## Initial AI context, custom prompts and persistence + +Mandatory application guidance is appended **after template variable resolution** +in `SystemPromptService.construct`. The unchanged default template and exact +user-authored custom-template bytes are preserved. An empty custom template now +constructs guidance-only context. Guidance includes actual syntax/examples, +save-before-reference, short summaries, supported paths/limits, local-only/live +semantics, preview/expand behavior and the distinction between user display and +reading a file for the model's own understanding. + +New conversations and normal prompt reconstruction (compaction, missing resolved +prompt, cwd/computer metadata invalidation) receive it, including installations +with existing custom templates. Resolved prompts remain persisted per conversation. +Existing matching resolved prompts, including legacy empty prompts, are returned +**byte-for-byte unchanged** until ordinary reconstruction; changing the global +template alone does not retrofit existing conversations. There is no migration or +read-time mutation. The pure suffix-idempotent `appendTextFileGuidance` helper is +also used for missing/stale read-only cache-warming assembly, yielding the same +provider system prompt as the next real turn without warm persistence writes. +Generic `resolveText` remains generic and does not append application guidance. + +The session-orchestrator tests capture actual provider requests using real prompt +service, conversation store, assembly and kernel turn loop: default/custom first +turns, legacy continued/warm prompts, missing/cwd/computer invalidation, warm/real +parity, compaction and later reuse. History retains the reference, not the file +body. This is verified delivery, not merely a default-string assertion. + +Explicit caller-owned per-turn `systemPrompt` overrides (e.g. heartbeat) retain +their existing **AS-IS bypass** contract; they do not receive appended guidance. +The initial instructions in already-running/persisted conversations are not +retroactively changed, and this task did not redeploy/restart the shared service. + +## Verification + +`bun install` completed inside both feature worktrees before build/testing; frontend +was refreshed after backend declarations were built. All work is on +`feature/text-file-embeds`, with no merge or push. After the user's stop request, +all delegated generations were stopped and the lead completed fixes, tests, +verification, documentation and commits directly; no further subagents. + +### Frontend + +- `bun run typecheck`: **0 errors / 0 warnings**. +- `bun run check`: clean, 308 files (no warnings/infos). +- `NODE_OPTIONS=--no-experimental-webstorage bun run test`: **2586 passed, + 2 opt-in live tests skipped**, twice, 130 passed files / one skipped file. +- Markdown-specific tests: **236 passed** (parser, streaming, sanitized UI, + image consent, head/tail/expand, code/plain text, request effects and limits). +- `bun run build`: succeeds; existing Svelte initial-value capture diagnostics + and large-chunk advisory remain. +- `TEXT_FILE_LIVE_PROBE=1 bunx vitest run scripts/text-file-live.test.ts`: + **2 passed** against a freshly spawned ephemeral Bun HTTP app using a real + conversation store and temp disk files. The actual Svelte component and client + render a saved plan from only summary+reference, expand the omitted middle, + refresh updated content, remount/reload current bytes, show deletion, recover on + Retry and reject authoritative remote context. The fixture process/files are + cleaned up. No deployed service is used or restarted. +- Plain `bun run test` on host **Node v26.11.1** reproduces 26 pre-existing + localStorage-related failures in untouched `tile-layout-store` and + `WorkspacesHome` suites. A temporary `git archive HEAD` baseline with the same + dependencies independently reproduced exactly those 26 failures. Disabling + Node's experimental Web Storage lets jsdom supply browser storage and makes + the whole suite pass; no unrelated application/test code was modified for this. + +### Backend + +- `bun run typecheck`: succeeds. +- `bun run test`: **4277 passed / 10 skipped**, 221 passed files / one skipped. +- `bun run test:bun`: **190 passed / 0 failed**, including two real ephemeral + loopback text-file HTTP tests in the standard Bun test target. +- `bun run check`: clean, 754 files (no warnings/infos). +- `bun build packages/host-bin/src/main.ts --compile --outfile dist/dispatch-server --minify` + and equivalent CLI compile: both succeed, worktree-local ignored outputs only. +- Standard `bin/build --no-frontend` cannot complete here: its existing sibling + `claude` symlink points at the separate main Claude checkout, where bundling + fails resolving `@dispatch/kernel`. The wrapper may have refreshed ignored compiler outputs there before failing; + no dependency install or tracked edits were made there; the standalone feature backend/CLI compile above is the verified + build. External Claude bundling is **not** claimed verified. +- Backend tests cover pure path/query/encoding policy, effective context, + strict UTF-8/binary/empty/exact-limit/oversize files, directories/devices/FIFO/ + symlink refusal, unknown conversation, stale guards, remote inheritance with + an existing local path, live changes/deletion and Host/Origin rejection. +- `git diff --check` clean in both repositories. No probe server/collector leaks. + +### Remaining manual validation + +Automated UI tests use jsdom, not a graphical browser. After deploying both +commits, visually confirm block styling/responsiveness on desktop/mobile, expand +and collapse a longer Markdown plan, and click a remote-image consent button. +No screenshots, graphical-browser layout check, AI-provider-generated live reply, +remote file support, watchers, snapshots or editor/attachment redesign are claimed. diff --git a/notes/text-file-embeds-plan.md b/notes/text-file-embeds-plan.md new file mode 100644 index 0000000..e56b3ed --- /dev/null +++ b/notes/text-file-embeds-plan.md @@ -0,0 +1,93 @@ +# Text-file embeds in chat Markdown + +## User request (verbatim) + +> I would like to add a new feature to the frontend: embedding of text files into the chat markdown. This is to save on the ai having to output verbatim content, when it can instead just link to it to show it in the chat. For example when making an new plan, it can just embed the plan. Or giving research, it can just embed it to display in the chat. This feature will also require a initial context update to explain to do this to the ai. Plan the feature and then summon a sol agent to implement it + +## Goal + +An assistant can save a plan, research report, or other text document to disk and reference it concisely in its reply. The chat displays that document inline without requiring the model to output its contents again. The reference remains the stored chat text; reading the embed for display must not inject its body back into model history. + +## Observed integration points + +- Frontend: `src/features/markdown/ui/Markdown.svelte`, `logic/markdown.ts`, and `logic/streaming-markdown.ts`. Rendering currently uses marked, highlight.js and DOMPurify. Its rendering core is synchronous/pure; streaming caches settled blocks separately from the changing tail. +- Chat wiring: `src/features/chat/ui/ChatView.svelte` supplies the Markdown component with the backend base URL and conversation cwd for local images. Audit the conversation computer context as well before resolving file references. +- Backend: `packages/transport-http/src/routes/images.ts` has a `/local-image` route; it serves local raster files, not text files. Inspect existing file/remote-execution contracts before introducing a text-serving seam. +- AI guidance: `packages/system-prompt/src/service.ts` defines `DEFAULT_TEMPLATE`, including existing image-embedding instructions. Stored custom templates take precedence over that fallback, and resolved prompts are persisted per conversation. Updating only the fallback may therefore not teach agents using an existing custom template. Trace the actual initial-context assembly and choose the appropriate non-destructive delivery point. + +These are discovery pointers, not a mandate to copy the image route's filesystem policy or to restructure the renderer. + +## Implementation plan + +### 1. Define the reference contract + +Implementation contract: a standalone paragraph link `[Plan](text-file:notes/plan.md)` opts into a text-file embed. An absolute path is also supported, e.g. `[Research](text-file:/tmp/research.md)`. For spaces use an angle-bracket destination, e.g. `[Research](<text-file:notes/research%20report.md>)`; percent escapes decode once after Markdown escapes, and literal percent signs use `%25`. Destinations are POSIX filesystem paths only, never `file:` URLs, HTTP URLs, tilde expansion, backslashes, or arbitrary network targets. Remote-computer text embeds are explicitly unsupported in this version (never fall back to reading the same path locally). A short labeled file reference is enough for an assistant to emit it. Ordinary Markdown links, images, inline code, and fenced code must retain their existing behavior. Syntax shown inside code must not fetch files. + +The syntax and its supported path forms must be identical across parser, tests, and AI instructions. Support absolute paths and paths relative to the conversation cwd. Decide explicitly how file URLs, spaces/encoding, and remote-computer paths work. Reject unsupported destinations rather than accidentally reading the same path from a different computer. Do not silently add arbitrary remote HTTP fetching. + +### 2. Provide bounded, authenticated file reads + +Reuse suitable existing contracts if available; otherwise expose the smallest transport operation required by the frontend. Keep access within the existing authenticated/authorized transport boundary and validate all request inputs. Resolve against the correct conversation/computer context, not the browser URL or server process cwd. + +Return text as data, never executable HTML. Bound file size and read work; handle non-regular files, binary/invalid text, unavailable paths, and remote errors gracefully. Ensure unsupported paths/protocols cannot become browser fetches or server-side URL fetches. Use a concrete, tested text-size policy and communicate any rejection or truncation in the UI. If extending contracts, follow package boundaries and existing route/composition patterns. + +### 3. Display an inline document + +Additional user requirement (verbatim): + +> the inline text should render the markdown, but it should be inside of a block like how tool calls are inside of an expandable block. It should show ~the first and last 6 lines of text from the markdown, and have an expand button to show more. + +This supersedes the generic document-container presentation. Render Markdown documents through the existing sanitized Markdown pipeline inside an expandable, tool-call-like block. The default preview displays rendered Markdown from approximately the first six and last six source lines, with a clear omission indicator between them; short documents must not duplicate overlapping lines. Provide an accessible Expand/Collapse control to show the full rendered document. Determine concrete styling from existing tool-call UI patterns. Both preview fragments and expanded content must be sanitized, preserve image-consent policy, and never recursively fetch nested embeds. Display other supported text/code files safely, preserving whitespace and using existing highlighting where suitable. Include the reference label/path so users can tell embedded content from the assistant's own prose. + +Keep fetching in an effectful UI/adapter layer rather than making pure Markdown rendering perform I/O. Provide visible loading, empty-file, and failure states, with a practical retry path. A failure must not break the surrounding chat. + +Integrate with streaming without repeatedly fetching on every token or replacing settled document state unnecessarily. Clean up stale requests on unmount/context changes and prevent late responses from rendering under a different path/conversation/computer. Repeated references should not cause uncontrolled duplicate work. + +Embedded content is untrusted: sanitize it, preserve existing remote-image consent, and do not recursively fetch embeds found within an embedded document. Preserve the existing security policy for raw HTML and links. Keep rendering bounded for large files. + +### 4. Specify persistence semantics + +Document whether an embed displays the current on-disk file or a captured snapshot. Prefer the simplest coherent implementation using existing infrastructure; do not imply historical immutability if the file is fetched live. State what happens when a file changes or is deleted and how refresh/retry behaves. Snapshots, file watchers, editors, and broad attachment-system redesign are not required by this request. + +### 5. Teach the AI in its initial context + +Explain the actual implemented syntax with a plan/research example, supported path rules, and relevant limitations. Tell the assistant to finish saving the file before emitting its reference, to use embeds instead of repeating long saved content when useful, and to retain a short explanatory summary in its reply. Make clear that embedding displays the file to the user; it is not a substitute for reading a file the assistant itself needs to understand. + +Verify guidance reaches the intended initial-context path for existing installations/custom templates without overwriting user-authored prompts. Respect existing prompt persistence semantics; explicitly report which conversations receive the new instructions and when. Add coverage for the delivery mechanism, not just a string assertion on an unused fallback. + +### 6. Verify and commit + +Read each repo's `AGENTS.md` and applicable scoped rules. Run `bun install` in both worktrees before verification. Implement within the isolated feature worktrees only, coordinating owners if delegating package work. + +Cover at least: +- Parsing and path resolution, escaped/encoded paths, non-embed links/images/code, incomplete streaming syntax. +- Markdown and plain-text display, XSS attempts, nested embeds, existing image-consent behavior. +- Loading, missing/empty/oversized/binary files, retry, context switches, stale responses, repeated references. +- Backend validation/access behavior and correct computer routing or explicit unsupported-computer rejection. +- Initial-context guidance and custom-template compatibility. +- End-to-end scenario: save a plan file, emit only its embed plus a short summary, and see its formatted contents in chat; reload and error behavior match documented semantics. + +Run frontend typecheck, tests, formatting/lint checks and production build. Run applicable backend typecheck, tests (including Bun-specific transport coverage as appropriate), formatting/lint checks and build verification according to its architecture guidance. Report pre-existing failures distinctly; do not silently claim passing checks. + +Commit verified work in both repositories on `feature/text-file-embeds`. Include final syntax, lifecycle semantics, verification results, and any limitations in the implementation report. Do not merge or push. + +## Acceptance criteria + +1. A short file reference displays a saved plan/research document inline in assistant chat without repeating its body in the model reply, inside a tool-call-like expandable block with rendered first/last approximately six-source-line previews and Expand/Collapse. +2. Existing ordinary Markdown behavior remains intact, including streamed messages. +3. File I/O and document rendering are bounded and remain within the existing transport trust and sanitization policies. The audited existing trust boundary is Host/Origin plus trusted-network deployment, not per-user/session authentication. +4. The AI receives accurate initial-context instructions through a verified path, without destroying custom prompts. +5. Both repositories contain committed, verified work on the feature branch; `dev` remains untouched. + +## Handoff + +This plan defines user-visible goals, integration observations, and acceptance criteria. The Sol implementation agent owns technical design, including final syntax and the minimal appropriate contract/architecture. Do not treat illustrative approaches as additional user requirements. Resolve details by inspecting the codebase and record the chosen behavior in the report. + +## Implementation outcome + +Implemented and verified; authoritative final syntax, lifecycle, context delivery, +limits and verification caveats are recorded in +`notes/text-file-embeds-implementation.md`. Existing persisted prompts are not migrated; +remote computer embeds are explicitly unsupported. The user requested no subagents +during implementation; all delegated generations were stopped and remaining work +was completed directly. No merge, push or shared deployment. diff --git a/scripts/text-file-live.test.ts b/scripts/text-file-live.test.ts new file mode 100644 index 0000000..18d1cb6 --- /dev/null +++ b/scripts/text-file-live.test.ts @@ -0,0 +1,133 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { rm, writeFile } from "node:fs/promises"; +import { resolve as resolvePath } from "node:path"; +import { createInterface } from "node:readline"; +import { transferableAbortController } from "node:util"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTextFileClient, Markdown } from "../src/features/markdown"; + +// Bridge only the outer fetch edge's jsdom signal to Node's native signal. +const networkFetch: typeof fetch = async (input, init) => { + const controller = transferableAbortController(); + const abort = () => controller.abort(); + init?.signal?.addEventListener("abort", abort, { once: true }); + if (init?.signal?.aborted) controller.abort(); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + init?.signal?.removeEventListener("abort", abort); + } +}; + +interface Fixture { + url: string; + path: string; + cwd: string; + conversationId: string; + remoteConversationId: string; +} + +// Opt-in real cross-repo UI/network probe. Owns an ephemeral loopback server and +// temporary files only; never connects to or restarts the deployed application. +// TEXT_FILE_LIVE_PROBE=1 bunx vitest run scripts/text-file-live.test.ts +// The backend must be installed and typechecked first (workspace public dists). +describe.skipIf(process.env.TEXT_FILE_LIVE_PROBE !== "1")("live saved-file chat display", () => { + let child: ChildProcess; + let fixture: Fixture; + beforeAll(async () => { + fixture = await new Promise<Fixture>((resolve, reject) => { + child = spawn("bun", ["packages/transport-http/src/text-file-probe.ts"], { + cwd: resolvePath(process.cwd(), "../backend"), + stdio: ["ignore", "pipe", "pipe"], + }); + const timer = setTimeout( + () => reject(new Error("Text-file fixture startup timed out")), + 15000, + ); + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", reject); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Text-file fixture exited (${code}): ${stderr}`)); + }); + if (!child.stdout) { + reject(new Error("Missing fixture stdout")); + return; + } + const lines = createInterface({ input: child.stdout }); + lines.once("line", (line) => { + clearTimeout(timer); + try { + resolve(JSON.parse(line) as Fixture); + } catch (error) { + reject(error); + } + }); + }); + }, 20000); + afterAll(async () => { + if (child && child.exitCode === null) { + await new Promise<void>((resolve) => { + const timer = setTimeout(() => child.kill("SIGKILL"), 3000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }); + } + }); + + it("renders only the saved reference, previews/expands Markdown, and rereads changed/deleted files on refresh and remount", async () => { + const saved = Array.from({ length: 20 }, (_, i) => `## Saved step ${i + 1}`).join("\n"); + await writeFile(fixture.path, saved); + const reply = "I saved the implementation plan.\n\n[Plan](text-file:plan%20100%25.md)"; + const client = createTextFileClient(fixture.url, fixture.conversationId, networkFetch); + let view = render(Markdown, { text: reply, textFiles: client }); + try { + await screen.findByRole("heading", { name: "Saved step 1" }); + expect(screen.getByRole("heading", { name: "Saved step 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Saved step 10" })).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Saved step 10" })).toBeInTheDocument(); + expect(reply).not.toContain(saved); + await writeFile(fixture.path, "# Updated on disk\n\n**Fresh content**"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByRole("heading", { name: "Updated on disk" }); + expect(view.container.querySelector("strong")?.textContent).toBe("Fresh content"); + view.unmount(); + view = render(Markdown, { text: reply, textFiles: client }); + await screen.findByRole("heading", { name: "Updated on disk" }); + await rm(fixture.path); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Text file not found"); + await writeFile(fixture.path, "# Recreated plan"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("heading", { name: "Recreated plan" }); + } finally { + view.unmount(); + client.dispose(); + } + }); + + it("does not read the same local path for an authoritative remote conversation", async () => { + const client = createTextFileClient(fixture.url, fixture.remoteConversationId, networkFetch); + const view = render(Markdown, { + text: `[Remote](text-file:${encodeURI(fixture.path)})`, + textFiles: client, + }); + try { + expect(await screen.findByRole("alert")).toHaveTextContent( + "not available for remote computers", + ); + expect(screen.queryByRole("heading", { name: "Recreated plan" })).not.toBeInTheDocument(); + } finally { + view.unmount(); + client.dispose(); + } + }); +}); diff --git a/src/app/conversation-settings.svelte.ts b/src/app/conversation-settings.svelte.ts index df259af..b853a8f 100644 --- a/src/app/conversation-settings.svelte.ts +++ b/src/app/conversation-settings.svelte.ts @@ -43,9 +43,11 @@ export function createConversationSettings(deps: { // The active conversation's persisted working directory (per-tab). Seeded from // the backend on focus change; null for a draft / when unset. let cwd = $state<string | null>(null); + let textFileContextRevision = $state(0); /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ async function refreshCwd(): Promise<void> { + textFileContextRevision++; const id = deps.workspaceConversationId(); try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); @@ -68,6 +70,7 @@ export function createConversationSettings(deps: { * so it is a silent no-op (mirrors `refreshCwd` for a draft). */ async function refreshComputer(): Promise<void> { + textFileContextRevision++; const id = deps.workspaceConversationId(); // Clear immediately so a switch never shows the PREVIOUS conversation's // computer while the fetch is in flight (null renders as "Local"). @@ -177,6 +180,9 @@ export function createConversationSettings(deps: { refreshThinking, refreshCompactPercent, refreshVisionSettings, + get textFileContextRevision() { + return textFileContextRevision; + }, get cwd() { return cwd; }, @@ -213,7 +219,10 @@ export function createConversationSettings(deps: { } const data = (await res.json()) as CwdResponse; const next = data.cwd ?? null; - if (deps.workspaceConversationId() === id) cwd = next; + if (deps.workspaceConversationId() === id) { + cwd = next; + textFileContextRevision++; + } return { ok: true, cwd: next }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; @@ -241,7 +250,10 @@ export function createConversationSettings(deps: { } const data = (await res.json()) as ConversationComputerResponse; const next = data.computerId ?? null; - if (deps.workspaceConversationId() === id) computerId = next; + if (deps.workspaceConversationId() === id) { + computerId = next; + textFileContextRevision++; + } return { ok: true, computerId: next }; } catch (err) { return { diff --git a/src/app/store-types.ts b/src/app/store-types.ts index 774d041..6835166 100644 --- a/src/app/store-types.ts +++ b/src/app/store-types.ts @@ -35,6 +35,7 @@ import type { HeartbeatStopResult, } from "../features/heartbeat"; import type { LayoutStore } from "../features/layout"; +import type { TextFileClient } from "../features/markdown"; import type { NamedAgent, NamedAgentDeleteResult, @@ -215,6 +216,9 @@ export interface AppStore { warmNow(): Promise<WarmResult | null>; /** The workspace conversation's persisted working directory, or null when unset. */ readonly cwd: string | null; + /** Invalidates live-file routing after known settings changes. */ + readonly textFileContextRevision: number; + createTextFileClient(conversationId: string): TextFileClient; /** The conversation workspace settings target: the active tab, or the pending draft's id. */ readonly currentConversationId: string; /** diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index fa12036..8fcc4cc 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -21,6 +21,7 @@ import { createLayoutStore, listPanelViews, } from "../features/layout"; +import { createTextFileClient } from "../features/markdown"; import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents"; import type { PluginsStore } from "../features/plugins"; import { createPluginsHttp, createPluginsStore } from "../features/plugins"; @@ -472,6 +473,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get storage() { return localStorageOpt; }, + get textFileContextRevision(): number { + return settings.textFileContextRevision; + }, + createTextFileClient(conversationId) { + return createTextFileClient(httpBase, conversationId, fetchImpl); + }, get cwd(): string | null { return settings.cwd; }, diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte index d132fad..b5e05bb 100644 --- a/src/app/views/ChatPane.svelte +++ b/src/app/views/ChatPane.svelte @@ -13,6 +13,16 @@ let { ports }: { ports: AppViewPorts } = $props(); const store = $derived(ports.store); + const textFiles = $derived.by(() => { + void store.activeWorkspaceId; + void store.textFileContextRevision; + const id = store.activeConversationId; + return id ? store.createTextFileClient(id) : undefined; + }); + $effect(() => { + const client = textFiles; + return () => client?.dispose(); + }); // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry). const cacheTimer = $derived( @@ -88,6 +98,7 @@ providerRetry={store.activeChat.providerRetry} apiBaseUrl={store.httpBase} cwd={store.cwd} + {textFiles} {cacheTimer} /> {/key} diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index c7f566a..84e66d8 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -11,7 +11,7 @@ viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import { Markdown, type TextFileClient } from "../../markdown"; import CacheTimerBadge from "./CacheTimerBadge.svelte"; import LazyCollapse from "./LazyCollapse.svelte"; import ToolCard from "./ToolCard.svelte"; @@ -32,6 +32,7 @@ providerRetry = null, apiBaseUrl = "", cwd = null, + textFiles = undefined, cacheTimer = null, }: { chunks: readonly RenderedChunk[]; @@ -68,6 +69,8 @@ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. */ cwd?: string | null; + /** Context-bound, injected live-file display port (never model history). */ + textFiles?: TextFileClient | undefined; /** * The cache timer (gaps between model requests): a running step gap shows * live at the bottom (under the executing tools), a running turn gap live @@ -216,6 +219,7 @@ text={rendered.chunk.text} streaming={rendered.streaming ?? false} image={{ apiBase: apiBaseUrl, cwd }} + {textFiles} /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> diff --git a/src/features/markdown/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts new file mode 100644 index 0000000..2597271 --- /dev/null +++ b/src/features/markdown/adapter/text-file.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TextFileState } from "../types"; +import { createTextFileClient } from "./text-file"; + +const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +const file = (text = "# Plan") => ({ + path: "/project/a.md", + text, + format: "markdown", + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, +}); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +describe("context-bound live-file client", () => { + it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + response(String(url).endsWith("-context") ? context : file()), + ); + const client = createTextFileClient("/api", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + const offA = client.subscribe("notes/100%.md", (s) => a.push(s)); + const offB = client.subscribe("notes/100%.md", (s) => b.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + path: "notes/100%.md", + expectedCwd: "/project", + expectedComputerId: "", + }); + expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store"); + expect(a.at(-1)?.kind).toBe("ready"); + expect(b.at(-1)?.kind).toBe("ready"); + offA(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + offB(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + client.subscribe("notes/100%.md", () => {}); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("never reads a file before context, handles remote inheritance explicitly", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValue(response({ ...context, computerId: "remote" })); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("/same-local-path.md", (s) => states.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toMatchObject({ + kind: "error", + message: expect.stringContaining("remote computers"), + }); + client.dispose(); + }); + it.each([ + 400, 404, 409, 413, 422, + ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response({ error: `failure ${status}` }, status)) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("changed"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` }); + client.refresh("a.md"); + await flush(); + expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } }); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("guards late responses after refresh and disposal", async () => { + let finish: (r: Response) => void = () => {}; + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("new"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + client.refresh("a.md"); + await flush(); + finish(response(file("old"))); + await flush(); + expect(states.at(-1)).toMatchObject({ file: { text: "new" } }); + client.dispose(); + expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true); + }); + it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + String(url).endsWith("-context") ? response(context) : new Promise(() => {}), + ); + const client = createTextFileClient("", "c", fetcher); + const overflow: TextFileState[] = []; + for (let i = 0; i < 40; i++) + client.subscribe(`${i}.md`, (s) => { + if (i >= 32) overflow.push(s); + }); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(overflow).toHaveLength(8); + expect(overflow[0]).toMatchObject({ kind: "error" }); + client.dispose(); + for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true); + }); + it("rejects oversized or malformed success data instead of truncating", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("a".repeat(262145)))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)?.kind).toBe("error"); + client.dispose(); + }); +}); + +it("invalidates every live document and reloads guards before publishing refreshed context", async () => { + let generation = 0; + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => { + if (String(url).endsWith("-context")) + return response({ ...context, cwd: generation ? "/new" : "/project" }); + const params = new URL(String(url), "http://localhost").searchParams; + return response({ + ...file(generation ? "new" : "old"), + path: `${params.get("expectedCwd")}/a.md`, + }); + }); + const client = createTextFileClient("", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + client.subscribe("a.md", (state) => a.push(state)); + client.subscribe("b.md", (state) => b.push(state)); + await flush(); + generation++; + client.refresh("a.md"); + expect(a.at(-1)?.kind).toBe("loading"); + expect(b.at(-1)?.kind).toBe("loading"); + await flush(); + expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } }); + expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } }); + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2); + client.dispose(); +}); diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts new file mode 100644 index 0000000..05ddb23 --- /dev/null +++ b/src/features/markdown/adapter/text-file.ts @@ -0,0 +1,171 @@ +import type { + TextFileContextResponse, + TextFileQuery, + TextFileResponse, +} from "@dispatch/transport-contract"; +import type { TextFileClient, TextFileState } from "../types"; + +const MAX_BYTES = 262144; +const MAX_ENTRIES = 32; +const MAX_PARALLEL = 4; +interface Entry { + readonly listeners: Set<(state: TextFileState) => void>; + state: TextFileState; + controller: AbortController; +} +/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */ +export function createTextFileClient( + apiBase: string, + conversationId: string, + fetchImpl: typeof fetch, +): TextFileClient { + const entries = new Map<string, Entry>(); + let disposed = false; + let contextController = new AbortController(); + let context: Promise<TextFileContextResponse> | null = null; + let active = 0; + const queue = new Map<string, () => void>(); + const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`; + async function json(url: string, signal: AbortSignal): Promise<unknown> { + const response = await fetchImpl(url, { signal, cache: "no-store" }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = data as { error?: string }; + throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`); + } + return data; + } + function getContext(): Promise<TextFileContextResponse> { + context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => { + const c = data as TextFileContextResponse; + if ( + c.conversationId !== conversationId || + !(c.cwd === null || typeof c.cwd === "string") || + !(c.computerId === null || typeof c.computerId === "string") + ) { + throw new Error("Invalid text file context response"); + } + return c; + }); + return context; + } + function publish(entry: Entry, state: TextFileState): void { + entry.state = state; + for (const listener of entry.listeners) listener(state); + } + async function load(path: string, entry: Entry, controller: AbortController): Promise<void> { + try { + if (controller.signal.aborted || disposed) return; + const c = await getContext(); + if (controller.signal.aborted || disposed) return; + if (c.computerId !== null) + throw new Error( + "Text-file embeds are not available for remote computers. No local file was read.", + ); + const query: TextFileQuery = { + path, + expectedCwd: c.cwd ?? "", + expectedComputerId: c.computerId ?? "", + }; + const params = new URLSearchParams(query as unknown as Record<string, string>); + const data = (await json( + `${endpoint}/text-file?${params}`, + controller.signal, + )) as TextFileResponse; + if ( + typeof data.text !== "string" || + typeof data.path !== "string" || + !data.path.startsWith("/") || + !["markdown", "text"].includes(data.format) || + data.maxBytes !== MAX_BYTES || + !Number.isInteger(data.bytes) || + data.bytes < 0 || + data.bytes > MAX_BYTES || + new TextEncoder().encode(data.text).length !== data.bytes + ) { + throw new Error("Invalid or oversized text file response (maximum 256 KiB)"); + } + if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data }); + } catch (error) { + if (!controller.signal.aborted && !disposed) + publish(entry, { + kind: "error", + message: error instanceof Error ? error.message : "Unable to read text file", + }); + } + } + function drain(): void { + while (active < MAX_PARALLEL && queue.size) { + const next = queue.entries().next().value; + if (!next) return; + queue.delete(next[0]); + next[1](); + } + } + function start(path: string, entry: Entry): void { + entry.controller.abort(); + const controller = new AbortController(); + entry.controller = controller; + publish(entry, { kind: "loading" }); + queue.set(path, () => { + if (controller.signal.aborted || disposed) return; + active++; + void load(path, entry, controller).finally(() => { + active--; + drain(); + }); + }); + drain(); + } + return { + subscribe(path, listener) { + if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) { + listener({ + kind: "error", + message: "Too many text-file embeds (maximum 32 active files).", + }); + return () => {}; + } + let entry = entries.get(path); + if (!entry) { + entry = { + listeners: new Set(), + state: { kind: "loading" }, + controller: new AbortController(), + }; + entries.set(path, entry); + start(path, entry); + } + entry.listeners.add(listener); + listener(entry.state); + const current = entry; + return () => { + current.listeners.delete(listener); + if (!current.listeners.size) { + current.controller.abort(); + entries.delete(path); + queue.delete(path); + } + if (!entries.size) { + contextController.abort(); + contextController = new AbortController(); + context = null; + } + }; + }, + refresh(_path) { + // Refresh can reveal a context change: invalidate ALL live content before rereading. + contextController.abort(); + contextController = new AbortController(); + context = null; + for (const [path, entry] of entries) start(path, entry); + }, + dispose() { + disposed = true; + contextController.abort(); + for (const entry of entries.values()) entry.controller.abort(); + entries.clear(); + queue.clear(); + }, + }; +} diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index ff3aefa..2b50ab1 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -6,3 +6,6 @@ export const manifest = { name: "markdown", description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; + +export { createTextFileClient } from "./adapter/text-file"; +export type { TextFileClient, TextFileContext, TextFileState } from "./types"; diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "<div data-text-file='a.md'>Plan</div>", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts new file mode 100644 index 0000000..cb3fe8d --- /dev/null +++ b/src/features/markdown/types.ts @@ -0,0 +1,13 @@ +import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract"; + +export type TextFileState = + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ready"; readonly file: TextFileResponse }; +export interface TextFileClient { + subscribe(path: string, listener: (state: TextFileState) => void): () => void; + /** Refresh context as well as the live on-disk file. */ + refresh(path: string): void; + dispose(): void; +} +export type TextFileContext = TextFileContextResponse; 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(); + }); +}); |
