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/App.svelte | |
| 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/App.svelte')
| -rw-r--r-- | src/app/App.svelte | 90 |
1 files changed, 83 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> |
