From 48cdc3d19a319981a8a4128be98db3c283f81790 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 24 Oct 2026 18:34:12 +0900 Subject: 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. --- src/app/conversation-settings.svelte.ts | 16 +- src/app/store-types.ts | 4 + src/app/store.svelte.ts | 7 + src/app/views/ChatPane.svelte | 11 ++ src/features/chat/ui/ChatView.svelte | 6 +- src/features/markdown/adapter/text-file.test.ts | 168 +++++++++++++++++++++ src/features/markdown/adapter/text-file.ts | 171 +++++++++++++++++++++ src/features/markdown/index.ts | 3 + src/features/markdown/logic/text-file.test.ts | 117 +++++++++++++++ src/features/markdown/logic/text-file.ts | 116 +++++++++++++++ src/features/markdown/types.ts | 13 ++ src/features/markdown/ui/Markdown.svelte | 125 +++------------- src/features/markdown/ui/MarkdownContent.svelte | 116 +++++++++++++++ src/features/markdown/ui/TextFileEmbed.svelte | 113 ++++++++++++++ src/features/markdown/ui/text-file.test.ts | 190 ++++++++++++++++++++++++ 15 files changed, 1069 insertions(+), 107 deletions(-) create mode 100644 src/features/markdown/adapter/text-file.test.ts create mode 100644 src/features/markdown/adapter/text-file.ts create mode 100644 src/features/markdown/logic/text-file.test.ts create mode 100644 src/features/markdown/logic/text-file.ts create mode 100644 src/features/markdown/types.ts create mode 100644 src/features/markdown/ui/MarkdownContent.svelte create mode 100644 src/features/markdown/ui/TextFileEmbed.svelte create mode 100644 src/features/markdown/ui/text-file.test.ts (limited to 'src') 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(null); + let textFileContextRevision = $state(0); /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ async function refreshCwd(): Promise { + 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 { + 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; /** 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"}