diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 00:22:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 00:22:42 +0900 |
| commit | fd565a6555e8bc9f37f21cf9d900523ef3be531b (patch) | |
| tree | ecf2c365c0c5e0ccdfc1a9ae350af933e4860ed2 /src/app | |
| parent | e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (diff) | |
| download | dispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.tar.gz dispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.zip | |
feat(workspace,smart-scroll): per-conversation cwd + LSP view; smart auto-scroll
workspace ([email protected]): 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.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 90 | ||||
| -rw-r--r-- | src/app/App.test.ts | 8 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 113 |
3 files changed, 204 insertions, 7 deletions
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<HTMLElement | undefined>(); + let transcriptContentEl = $state<HTMLElement | undefined>(); + + // 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<CwdSaveResult | null> { + 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<LspStatusResult | null> { + 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 }; + } </script> <main class="relative flex h-screen overflow-hidden"> @@ -134,10 +192,14 @@ </div> {/if} - <div class="relative min-w-0 flex-1 overflow-y-auto"> - {#key store.activeConversationId} - <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> - {/key} + <div class="relative min-h-0 min-w-0 flex-1"> + <div bind:this={transcriptEl} class="h-full overflow-y-auto"> + <div bind:this={transcriptContentEl}> + {#key store.activeConversationId} + <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> + {/key} + </div> + </div> {#if store.activeChat.chunks.length === 0} <div class="pointer-events-none absolute inset-0 flex items-center justify-center" @@ -146,6 +208,7 @@ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> </div> {/if} + <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> </div> <Composer onSend={handleSend} /> @@ -185,7 +248,20 @@ {#snippet viewContent(kind: string)} {#if kind === "model"} - <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + <div class="flex flex-col gap-3"> + <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + <!-- Keyed on the workspace conversation (active tab OR draft) so the input + re-mounts per conversation — incl. switching between drafts — and can't + bleed across tabs. Editable for a draft too (cwd applies from turn 1). --> + {#key store.currentConversationId} + <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + {/key} + </div> + {:else if kind === "lsp"} + <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> + {#key store.currentConversationId} + <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> + {/key} {:else if kind === "extensions"} <section> <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> 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<WarmResult | null>; + /** 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<CwdResult | null>; + /** + * 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<LspResult | null>; dispose(): void; } @@ -160,6 +187,24 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let activeChat = $state<ChatStore>(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<string | null>(null); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise<void> { + 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<CwdResult | null> { + 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<LspResult | null> { + 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<LspStatusResponse>; + 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(); |
