summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
committerAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
commitfd565a6555e8bc9f37f21cf9d900523ef3be531b (patch)
treeecf2c365c0c5e0ccdfc1a9ae350af933e4860ed2 /src/app
parente45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (diff)
downloaddispatch-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.svelte90
-rw-r--r--src/app/App.test.ts8
-rw-r--r--src/app/store.svelte.ts113
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();