From fd565a6555e8bc9f37f21cf9d900523ef3be531b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 00:22:42 +0900 Subject: feat(workspace,smart-scroll): per-conversation cwd + LSP view; smart auto-scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace (transport-contract@0.5.0): a cwd field in the Model sidebar view (GET/PUT /conversations/:id/cwd) + a new 'Language Servers' view (GET /conversations/:id/lsp) with per-server connected/starting/error badges, spinner, error text, and refresh. Store-owned reactive cwd, re-seeded on focus change; works for DRAFTS too (targets the draft's client-minted id, which survives promotion, so turn 1 runs in the chosen cwd). Network seam normalizes the untyped LSP body. smart-scroll: pure stick-to-bottom reducer + injected controller shell (scroll/scrollend + a ResizeObserver on the content so the view follows async height changes — markdown/highlight, images, collapses, viewport reflow), plus a floating scroll-to-bottom button. FIX: restore the transcript scrollbar — the refactor moved overflow-y-auto to an inner child, so the flex-1 container needed min-h-0 to constrain instead of growing to content. harness: vitest-setup polyfills Element.scrollTo + ResizeObserver (jsdom implements neither), fixing App component tests. docs: backend-handoff pruned (CR-3 resolved/removed); added cwd/LSP verification courier (backend confirmed all 6 asks ✅); removed the resolved cache-warming-timer courier. Verified: svelte-check 0 errors, biome clean, 523 tests pass, vite build OK. --- src/app/App.svelte | 90 +++++++++++++++++++++++++++++++++++--- src/app/App.test.ts | 8 ++++ src/app/store.svelte.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 7 deletions(-) (limited to 'src/app') diff --git a/src/app/App.svelte b/src/app/App.svelte index dae6177..daab953 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -9,9 +9,21 @@ import { ChatView, Composer, manifest as chatManifest, ModelSelector } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; + import { + createSmartScrollController, + manifest as smartScrollManifest, + ScrollToBottom, + } from "../features/smart-scroll"; import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; import { manifest as tabsManifest, TabBar } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; + import { + CwdField, + type CwdSaveResult, + LspStatusView, + type LspStatusResult, + manifest as workspaceManifest, + } from "../features/workspace"; import type { AppStore } from "./store.svelte"; let { store }: { store: AppStore } = $props(); @@ -26,12 +38,13 @@ // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ { id: "model", label: "Model" }, + { id: "lsp", label: "Language Servers" }, { id: "extensions", label: "Extensions" }, { id: "cache-warming", label: "Cache Warming" }, ] as const; - // Default sidebar layout: a Model panel on top, then Extensions, then Cache Warming. - const initialViews = ["model", "extensions", "cache-warming"] as const; + // Default sidebar layout: Model panel on top, then Language Servers, Extensions, Cache Warming. + const initialViews = ["model", "lsp", "extensions", "cache-warming"] as const; // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -47,8 +60,38 @@ conversationCacheManifest, markdownManifest, cacheWarmingManifest, + workspaceManifest, + smartScrollManifest, ].map((m) => [m.name, m.description] as const); + // Smart-scroll: keep the transcript pinned to the bottom while it streams, + // unless the reader has scrolled up (then show a "scroll to bottom" button). + // One controller owns the chat scroll region; effects below feed it the edges. + const smartScroll = createSmartScrollController(); + let transcriptEl = $state(); + let transcriptContentEl = $state(); + + // Attach/detach the controller to the live scroll element + content (disposed on + // unmount). The content element is observed (ResizeObserver) so the view follows + // height changes that aren't a transcript append. + $effect(() => { + if (!transcriptEl) return; + return smartScroll.attach(transcriptEl, transcriptContentEl); + }); + + // New transcript content streamed in (or messages loaded) → follow the bottom + // while stuck. Reads `chunks.length` so the effect re-runs on every append. + $effect(() => { + void store.activeChat.chunks.length; + smartScroll.contentChanged(); + }); + + // Conversation/tab switch → snap to the bottom of the new transcript. + $effect(() => { + void store.activeConversationId; + smartScroll.reset(); + }); + // Right sidebar: open by default on wide screens (pushes the chat aside), // closed by default on narrow screens (overlays the chat). Initial state is // derived from the viewport width once; the hamburger toggles it thereafter. @@ -79,6 +122,21 @@ } : { ok: false, error: result.error }; } + + // Adapt the store's cwd/LSP results to the workspace feature's ports. + async function saveCwd(cwd: string): Promise { + const result = await store.setCwd(cwd); + if (result === null) return null; + return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; + } + + async function loadLspStatus(): Promise { + const result = await store.lspStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + }
@@ -134,10 +192,14 @@ {/if} -
- {#key store.activeConversationId} - - {/key} +
+
+
+ {#key store.activeConversationId} + + {/key} +
+
{#if store.activeChat.chunks.length === 0}
Dispatch
{/if} + smartScroll.resume()} />
@@ -185,7 +248,20 @@ {#snippet viewContent(kind: string)} {#if kind === "model"} - +
+ + + {#key store.currentConversationId} + + {/key} +
+ {:else if kind === "lsp"} + + {#key store.currentConversationId} + + {/key} {:else if kind === "extensions"}

Frontend modules

diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 1534d1c..d22f84b 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -62,6 +62,14 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); }; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index c242d77..6991530 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -3,7 +3,10 @@ import type { ChatErrorMessage, ConversationHistoryResponse, ConversationMetricsResponse, + CwdResponse, + LspStatusResponse, ModelsResponse, + SetCwdRequest, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; @@ -38,6 +41,16 @@ export type WarmResult = | { readonly ok: true; readonly response: WarmResponse } | { readonly ok: false; readonly error: string }; +/** Outcome of `PUT /conversations/:id/cwd`. */ +export type CwdResult = + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /conversations/:id/lsp`. */ +export type LspResult = + | { readonly ok: true; readonly response: LspStatusResponse } + | { readonly ok: false; readonly error: string }; + export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; @@ -61,6 +74,20 @@ export interface AppStore { * Returns null when no conversation is focused (a draft has nothing to warm). */ warmNow(): Promise; + /** The workspace conversation's persisted working directory, or null when unset. */ + readonly cwd: string | null; + /** The conversation workspace settings target: the active tab, or the pending draft's id. */ + readonly currentConversationId: string; + /** + * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). + * Works for a draft too (its id survives promotion), so the first turn runs in it. + */ + setCwd(cwd: string): Promise; + /** + * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). + * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. + */ + lspStatus(): Promise; dispose(): void; } @@ -160,6 +187,24 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let activeChat = $state(draftStore as ChatStore); + // 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); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); + if (!res.ok) return; + const data = (await res.json()) as CwdResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) cwd = data.cwd ?? null; + } catch { + // Non-fatal: a cwd fetch failure just leaves the prior value. + } + } + function getActiveChat(): ChatStore { const activeId = tabsStore.activeConversationId; if (activeId === null) { @@ -199,6 +244,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return tabsStore.activeConversationId ?? undefined; } + /** + * The conversation id workspace settings (cwd / LSP) target: the active tab, or + * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this + * is NEVER undefined — the draft has a stable client-minted id that survives + * promotion (first send), so a cwd set on a draft carries into the real turn. + */ + function workspaceConversationId(): string { + return tabsStore.activeConversationId ?? draftConversationId; + } + function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); // Surfaces are auto-expanded: whenever the catalog changes, subscribe to @@ -298,6 +353,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } refreshActiveChat(); + void refreshCwd(); return { get tabs(): readonly Tab[] { @@ -329,6 +385,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get lastError() { return protocol.lastError; }, + get cwd(): string | null { + return cwd; + }, + get currentConversationId(): string { + return workspaceConversationId(); + }, surface(surfaceId: string): SurfaceSpec | null { return getSurfaceSpec(protocol, surfaceId); @@ -356,6 +418,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // The draft became a real conversation: re-scope conversation-scoped // surfaces (e.g. cache-warming) to its id. syncSubscriptions(); + void refreshCwd(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -381,6 +444,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftConversationId = nextDraftId; refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, selectTab(conversationId: string): void { @@ -391,6 +455,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, closeTab(conversationId: string): void { @@ -403,6 +468,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void cache.delete(conversationId); refreshActiveChat(); syncSubscriptions(); + void refreshCwd(); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { @@ -438,6 +504,53 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; } }, + + async setCwd(value: string): Promise { + const id = workspaceConversationId(); + const body: SetCwdRequest = { cwd: value }; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; + } + const data = (await res.json()) as CwdResponse; + const next = data.cwd ?? null; + if (workspaceConversationId() === id) cwd = next; + return { ok: true, cwd: next }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; + } + }, + + async lspStatus(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial; + const response: LspStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "LSP status request failed", + }; + } + }, dispose(): void { for (const store of chatStores.values()) { store.dispose(); -- cgit v1.2.3