summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/transport-contract.reference.md59
-rw-r--r--backend-handoff-cache-warming-timer.md80
-rw-r--r--backend-handoff-cwd-lsp.md61
-rw-r--r--backend-handoff.md60
-rw-r--r--src/app/App.svelte90
-rw-r--r--src/app/App.test.ts8
-rw-r--r--src/app/store.svelte.ts113
-rw-r--r--src/features/smart-scroll/index.ts25
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts103
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts93
-rw-r--r--src/features/smart-scroll/ui/ScrollToBottom.svelte36
-rw-r--r--src/features/smart-scroll/ui/controller.svelte.ts130
-rw-r--r--src/features/smart-scroll/ui/controller.test.ts172
-rw-r--r--src/features/workspace/index.ts14
-rw-r--r--src/features/workspace/logic/view-model.test.ts101
-rw-r--r--src/features/workspace/logic/view-model.ts130
-rw-r--r--src/features/workspace/ui/CwdField.svelte96
-rw-r--r--src/features/workspace/ui/LspStatusView.svelte127
-rw-r--r--vitest-setup.ts14
19 files changed, 1393 insertions, 119 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 08f07ce..c2e2076 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -23,6 +23,16 @@
> `cache-warming-timer` payload + second "cache retention" `stat` ride the EXISTING `custom`/`stat`
> kinds; the FE cache-warming feature parses them.)
>
+> **2026-06-11 delta (LSP + cwd handoff — package bumped to `0.5.0`):** adds per-conversation working
+> directory `GET /conversations/:id/cwd` + `PUT /conversations/:id/cwd` (`CwdResponse`/`SetCwdRequest`,
+> CORS now allows `PUT`) and per-conversation LSP status `GET /conversations/:id/lsp`
+> (`LspStatusResponse`/`LspServerInfo`/`LspServerState`). The LSP GET LAZILY spawns+initializes the
+> configured servers (can take a moment the first time per cwd; cached after) and returns once each
+> server settles to `connected`/`error`. `servers` is `[]` when `cwd` is null. A `/chat`(`/warm`)
+> request that omits `cwd` now defaults to the conversation's persisted cwd; one that sends `cwd`
+> persists it. Consumed FE-side by the `workspace` feature (cwd field in the Model view + a
+> "Language Servers" view).
+>
> **0.3.0 change (token + timing metrics):** adds the durable metrics READ endpoint
> `GET /conversations/:id/metrics` → `ConversationMetricsResponse` (`{ turns: TurnMetrics[] }`), and
> re-exports `StepMetrics` / `TurnMetrics` from `@dispatch/wire`. This is a SEPARATE read axis from
@@ -48,6 +58,12 @@
missing/invalid `conversationId`. The warm is NEVER persisted/streamed/folded into real usage.
- `GET /metrics/throughput?period=day|week|month&date=<...>` — `ThroughputResponse` (token-weighted
tokens/sec per model over the window). Not part of cache-warming; listed for completeness.
+- `GET /conversations/:id/cwd` — `CwdResponse` (`cwd` is `null` until set).
+- `PUT /conversations/:id/cwd` — body `SetCwdRequest` → `200 CwdResponse`; `400 { error }` if `cwd`
+ missing/empty. CORS allows `PUT`.
+- `GET /conversations/:id/lsp` — `LspStatusResponse`. LAZILY spawns+initializes the configured servers
+ on the first call per cwd (can take a moment; cached after); returns once each settles to
+ `connected`/`error`. `servers` is `[]` when `cwd` is null.
- WebSocket on :24205 — ONE path-agnostic socket multiplexes surface ops
(`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive
`WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`**
@@ -192,6 +208,49 @@ export interface WarmResponse {
readonly expectedCacheRate: number;
}
+// ─── Per-conversation working directory (cwd) ─────────────────────────────────
+
+/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
+export interface CwdResponse {
+ readonly conversationId: string;
+ readonly cwd: string | null;
+}
+
+/** Body of `PUT /conversations/:id/cwd`. */
+export interface SetCwdRequest {
+ readonly cwd: string;
+}
+
+// ─── Per-conversation LSP status ──────────────────────────────────────────────
+
+/** The connection state of a single language server for a workspace. */
+export type LspServerState = "connected" | "starting" | "error" | "not-started";
+
+/** One language server's status as reported to the frontend. */
+export interface LspServerInfo {
+ /** Stable server id, e.g. "typescript", "luau-lsp". */
+ readonly id: string;
+ /** Human-readable display name. */
+ readonly name: string;
+ /** The resolved workspace root the server is (or would be) rooted at (absolute). */
+ readonly root: string;
+ /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
+ readonly extensions: readonly string[];
+ /** Current connection state. */
+ readonly state: LspServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+}
+
+/** Response of `GET /conversations/:id/lsp`. */
+export interface LspStatusResponse {
+ readonly conversationId: string;
+ /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */
+ readonly cwd: string | null;
+ /** The language servers configured for `cwd` and their live state. */
+ readonly servers: readonly LspServerInfo[];
+}
+
// ─── WebSocket chat ops ───────────────────────────────────────────────────────
// The persistent WS connection multiplexes chat ops (below) with surface ops
// (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they
diff --git a/backend-handoff-cache-warming-timer.md b/backend-handoff-cache-warming-timer.md
deleted file mode 100644
index 2b1e8c7..0000000
--- a/backend-handoff-cache-warming-timer.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# FE → backend handoff — cache-warming: next-warm timestamp + manual-warm timer reset
-
-> **Courier doc** (dispatch-web → arch-rewrite, carried by the user). `lsp` does not span the repos.
-> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
-> Focused ask split out of `backend-handoff.md` CR-3. Two requests, both ADDITIVE / backward-compatible.
-
-## Why
-The FE shipped a Cache Warming view: enabled toggle, minutes+seconds interval, a **live countdown to
-the next warm**, a manual **Warm now** button, and a **history** of each warm's hit %. Two of those —
-the countdown and a reliably-fresh "last cache %" — can't be done accurately from what the wire
-exposes today. The FE currently fakes them best-effort (countdown anchored to the last warm the FE
-*observed* + interval; manual warm % taken from the HTTP response because the surface doesn't refresh).
-We'd like to make them authoritative.
-
-## What I found in the backend (so these asks are precise, not guesses)
-Read of `packages/cache-warming/src/warmer.ts` + `packages/transport-http/src/app.ts`:
-
-1. **`POST /chat/warm` bypasses the warmer.** The handler (`transport-http/src/app.ts:240–289`) calls
- `warmService.warm(conversationId, …)` **directly**. It never goes through `CacheWarmer`, so a manual
- warm does NOT: re-arm the automatic timer, update the warmer's `lastPct`, or call `onSurfaceChange()`.
- ⇒ the cache-warming **surface does not refresh** after a manual warm (no `update` pushed), and the
- automatic timer keeps counting from the *previous* warm — a manual warm doesn't reset the cycle.
-2. **No next-warm time is tracked.** `armTimer` (`warmer.ts:99`) arms a relative
- `timers.setTimer(fn, state.intervalMs)` but stores no absolute fire time, and `ConversationState`
- (`warmer.ts:61`) is `{ enabled, intervalMs, active, lastPct, token }`. There is nothing the surface
- could carry to tell a client *when* the next warm fires.
-
-## Ask 1 — serve a machine-readable next-warm (and last-warm) timestamp on the surface
-Record the absolute fire time when the timer is armed and expose it (plus the last warm's time) on the
-**conversation-scoped `cache-warming` surface**, pushed on every change via the existing
-`onSurfaceChange()` (warm completes, toggle, interval change, turn start/settle).
-
-- `nextWarmAt`: epoch-ms the next automatic warm is scheduled to fire, or `null` when not scheduled
- (disabled, or `active` i.e. a turn is generating so the timer is cancelled).
-- `lastWarmAt`: epoch-ms of the most recent completed warm, or `null` if none yet.
-
-**Suggested shape — no `@dispatch/ui-contract` bump needed:** add ONE `custom` field to the spec
-(the escape hatch already exists; non-supporting clients gracefully skip it):
-```ts
-{
- kind: "custom",
- rendererId: "cache-warming-timer",
- payload: {
- nextWarmAt: number | null, // epoch-ms, or null when not scheduled
- lastWarmAt: number | null, // epoch-ms, or null when never warmed
- },
-}
-```
-(If you'd rather not add a field, a `stat` carrying an ISO/epoch string works too, but a machine-
-readable `custom` payload is cleanest for the countdown — the FE needs the number, not a display
-string.) The FE will read this in its cache-warming feature and render an exact countdown; it already
-parses the surface fields itself, so wiring a `cache-warming-timer` renderer is a small FE change.
-
-## Ask 2 — reset the automatic timer on a manual warm (and refresh the surface)
-Confirm or change: a manual `POST /chat/warm` should be treated as "a warm just happened" for that
-conversation, i.e. route it through (or notify) the `CacheWarmer` so it:
-1. **re-arms the automatic timer** from *now* (the countdown restarts at the full interval), and
-2. **updates `lastPct`** from the manual warm's result and **calls `onSurfaceChange()`** so subscribers
- get an `update` (this also fixes the surface "last cache %" not refreshing after a manual warm).
-
-Per the source, none of this happens today (Ask-1 finding #1). The minimal change is a
-`CacheWarmer.warmNow(conversationId)` that does what the automatic `fireWarm` already does — warm →
-set `lastPct` → `onSurfaceChange()` → `armTimer()` — and have the `/chat/warm` handler call THAT
-instead of `warmService.warm` directly. (If you intend manual warms to be a *separate*, non-cycle-
-resetting probe, tell us and we'll keep the FE's manual entry purely local — but the user's intent is
-that a manual warm resets the cycle.)
-
-## What the FE does once this lands
-- Drop the FE's best-effort countdown anchor and use `nextWarmAt` directly → exact, drift-free
- countdown that also reflects generation pauses (null while `active`).
-- Render history from the authoritative surface signal (using `lastWarmAt` changes), removing the
- FE-side de-dup/identical-pct workaround noted in `reports/cache-warming-feature.md`.
-
-## References
-- Backend: `packages/cache-warming/src/warmer.ts`, `packages/transport-http/src/app.ts:240`,
- `packages/cache-warming/src/pure.ts` (surface spec builder), the cache-warming surface
- (`id:"cache-warming"`, region `"side"`, conversation-scoped).
-- FE: `src/features/cache-warming/` (view-model + view), `backend-handoff.md` CR-3 (superseded by this
- doc), the original `frontend-cache-warming-handoff.md`.
-- No contract version bump required if Ask 1 uses the `custom` escape hatch; Ask 2 is server-internal.
diff --git a/backend-handoff-cwd-lsp.md b/backend-handoff-cwd-lsp.md
new file mode 100644
index 0000000..d896deb
--- /dev/null
+++ b/backend-handoff-cwd-lsp.md
@@ -0,0 +1,61 @@
+# FE handoff — cwd + LSP consumed; please VERIFY these backend behaviors
+
+> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
+> Focused courier doc (the living seam is `backend-handoff.md`). `lsp references` does not span the
+> two repos, so this is the cross-repo channel. Re: your `frontend-lsp-cwd-handoff.md`
+
+## What the FE built (so you know what's now exercising your endpoints)
+
+A new `workspace` feature consumes the cwd + LSP endpoints:
+- **cwd field** in the Model sidebar panel — `GET /conversations/:id/cwd` to seed, `PUT` to set.
+- **"Language Servers" sidebar view** — `GET /conversations/:id/lsp`, rendering each `LspServerInfo`
+ as a `connected`/`starting`/`error`/`not-started` badge (spinner while transient, `error` text shown
+ inline), with a manual Refresh. Loaded on mount and whenever the cwd changes.
+- The FE **normalizes the untyped LSP body** at the network seam (a missing/partial `servers` ⇒ `[]`),
+ so a malformed response can't crash the UI.
+
+**Key design point that drives the asks below:** the FE lets the user set the cwd / view LSP **for a
+DRAFT conversation that has not sent any message yet.** A draft already has a stable, client-minted
+`conversationId` (the FE mints ids and sends them on `chat.send`); that same id is reused when the
+draft is promoted on first send. So a cwd set on a draft must carry into its first real turn.
+
+## Please CONFIRM / ensure correct
+
+1. **Unseen-id graceful reads (CRITICAL).** For a `conversationId` the backend has **never seen**
+ (a fresh draft id — no `/chat`, no prior write):
+ - `GET /conversations/:id/cwd` ⇒ **`200 { conversationId, cwd: null }`** (not 404/500).
+ - `GET /conversations/:id/lsp` ⇒ **`200 { conversationId, cwd: null, servers: [] }`** (not 404/500).
+ The FE polls both for drafts on app load / panel mount. If an unseen id errors, the draft
+ Language-Servers panel shows a spurious error and the cwd field can't seed. Your handoff says
+ "cwd is null until set," which implies this — please confirm it holds for a **brand-new** id.
+
+2. **`PUT /conversations/:id/cwd` on an unseen/draft id persists it.** A `PUT` with a client-minted id
+ that has had no `/chat` yet should `200` and persist, keyed purely by id (the conversation need not
+ "exist" yet). Confirm the cwd store doesn't require a prior turn / row.
+
+3. **cwd defaulting carries the draft cwd into turn 1.** Sequence: FE `PUT /conversations/D/cwd {cwd}`
+ → then `chat.send`/`POST /chat` with `conversationId: D` and **no `cwd` field**. Per your handoff's
+ "cwd defaulting," that turn must run in the persisted `D` cwd. Confirm this works when the cwd PUT is
+ the FIRST thing that ever touched conversation `D`.
+
+4. **CORS preflight for `PUT`.** The handoff says CORS now allows `PUT`; please confirm the browser
+ **preflight** (`OPTIONS /conversations/:id/cwd` with `Access-Control-Request-Method: PUT`) is
+ answered, not just the `PUT` itself — otherwise the browser blocks the request before it's sent.
+
+5. **No spawn when cwd is null.** `GET /lsp` with `cwd: null` returns `servers: []` **without** spawning
+ any language server (so draft polling never spawns). Confirm the lazy spawn only happens once a cwd
+ is set.
+
+6. **Error body shape.** On a 4xx/5xx the FE reads `{ error: string }` (e.g. the `400` from an
+ empty-cwd `PUT`). Confirm error responses use that shape so the FE surfaces the reason.
+
+## FE behavior notes (no action needed — FYI)
+- LSP status is **HTTP-polled** (panel mount / cwd change / manual Refresh). A WS/surface push for LSP
+ status would let the FE drop the manual refresh and reflect live state flips — listed as a future ask
+ in `backend-handoff.md` §3, NOT requested now.
+- The FE shows the `LspServerInfo.error` text verbatim (e.g. `ENOENT ... posix_spawn`), per your
+ operational note about binaries needing to be on the daemon PATH.
+
+**None of these are blocking** — they are correctness confirmations for the draft path the FE now
+exercises. If (1) or (3) don't hold as assumed, that's the one thing that would need a backend change.
diff --git a/backend-handoff.md b/backend-handoff.md
index 69deebf..99c2964 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,39 +5,33 @@
> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (ORCHESTRATOR §5) — every cross-repo ask flows through here.
-_Last updated: 2026-06-11 — **Cache-rate fix + retention + CR-3 consumed FE-side** (from `frontend-cache-warming-handoff.md`): (1) per-turn cache rate now reads true on Claude (no FE change); (2) NEW cross-turn **expected cache (retention)** metric in the chat metrics bubble (`computeExpectedCachePct`/`viewExpectedCache`); (3) **CR-3 DONE & consumed** — the countdown is now AUTHORITATIVE off the surface's `cache-warming-timer` `nextWarmAt`/`lastWarmAt` (FE guessing dropped), history keyed off `lastWarmAt`, and `WarmResponse.expectedCacheRate` headlined on "Warm now"; (4) second "cache retention" `stat` parsed. transport mirror regenerated. **Earlier (same day):** `NumberField` + conversation-scoped subscriptions + "Cache Warming" sidebar view. Open asks: CR-1 (Loaded Extensions as a real multi-column table); CR-2 (optional catalog `scope` flag). **CR-3 is RESOLVED** (see §2)._
+_Last updated: 2026-06-11. **FE is current on `[email protected]`.** All handoffs to date are
+consumed: surfaces + WS, conversation transcript/metrics, tabs + model selector, cache-warming (incl.
+authoritative timer + retention + cache-rate fix), and **per-conversation cwd + LSP status** (new
+`workspace` feature — cwd field in the Model view + a "Language Servers" view; works for drafts too).
+**Open asks:** CR-1 (Loaded Extensions as a real table) + CR-2 (optional catalog `scope` flag) below.
+The cwd/LSP draft-path verification (`backend-handoff-cwd-lsp.md`) came back **all ✅ confirmed** by the
+backend (answers in their `frontend-lsp-cwd-handoff.md`) — see §2._
---
## 1. Pinned backend contracts (consumed by the FE)
-Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
+Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
| Package | Used for |
|---|---|
| `@dispatch/ui-contract` | surfaces + surface WS protocol |
| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs` |
-| `@dispatch/transport-contract` | `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + WS chat ops + `WsClientMessage`/`WsServerMessage` |
+| `@dispatch/transport-contract` | `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` |
-Endpoints in use (HTTP **24203**, WS **24205**, CORS `*`):
+Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`):
`POST /chat` (NDJSON) · `GET /models` · `GET /conversations/:id?sinceSeq=<n>` ·
-`GET /conversations/:id/metrics` · WS `chat.send`→`chat.delta`.
+`GET /conversations/:id/metrics` · `GET`/`PUT /conversations/:id/cwd` ·
+`GET /conversations/:id/lsp` · `POST /chat/warm` · WS `chat.send`→`chat.delta`.
Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md`
-(regenerate on any contract bump).
-
-**2026-06-11 re-mirror (cache-warming).** Both `ui-contract` and `transport-contract` were left at their
-existing versions by the backend (`[email protected]`, `[email protected]`) but gained ADDITIVE
-members; the `file:` deps already resolve them. The FE mirrors were regenerated to match:
-- `ui-contract.reference.md`: `NumberField` (`kind:"number"`) + optional `conversationId?` on
- `Subscribe`/`Unsubscribe`/`Invoke`/`Surface`/`SurfaceUpdate`.
-- `transport-contract.reference.md`: `POST /chat/warm` (`WarmRequest`/`WarmResponse`) + the throughput
- axis (`GET /metrics/throughput`, `ThroughputResponse`/`ThroughputModelStat`/`ThroughputPeriod`).
-- FE consumed: generic `number` renderer; protocol keyed by `surfaceId` carrying the focused
- conversationId with a staleness rule (drop a `surface`/`update` echoing a non-current conversation;
- a global no-echo reply is always accepted); store auto-subscribes every catalog surface with the
- focused conversationId and re-scopes on conversation switch; `warmNow()` posts `/chat/warm` with the
- conversation's current model name.
+(regenerate on any contract bump; all current as of `[email protected]`).
## 2. Open asks FOR THE BACKEND
@@ -90,24 +84,26 @@ trip per global surface per switch; no user-visible bug, the old spec is retaine
flicker). An optional `scope?: "global" | "conversation"` on `SurfaceCatalogEntry` would let the FE
skip re-subscribing globals on switch. **Not blocking** — only raise if cheap.
-### CR-3 — next-warm timestamp + manual-warm timer reset → **RESOLVED ✅ (backend `bfbad3a`, consumed FE-side)**
+### cwd + LSP draft path → **VERIFIED ✅ (all 6 asks confirmed; courier `backend-handoff-cwd-lsp.md`)**
-Both asks shipped by the backend (no contract bump — `custom` escape hatch) and are now consumed:
-1. **`nextWarmAt` / `lastWarmAt` (epoch-ms)** arrive on the conversation-scoped `cache-warming` surface
- as a `custom` field `{ rendererId: "cache-warming-timer", payload: { nextWarmAt, lastWarmAt } }`.
- FE: `parseControls` reads them; the countdown is now derived straight from `nextWarmAt`
- (`secondsUntilNext(nextWarmAt, now)`) and the history keys off `lastWarmAt` (`observeWarm`). The
- old FE best-effort anchor/guess logic was DELETED.
-2. **Manual `POST /chat/warm` now re-arms the timer + pushes a surface `update`.** FE: dropped the
- workaround of recording history from the HTTP response — history is driven authoritatively by the
- surface's `lastWarmAt`; the HTTP `WarmResponse` is still used for the immediate "Warm now" feedback
- line (now headlining `expectedCacheRate`). The generic surface-host does NOT render
- `cache-warming-timer` (no registered renderer → graceful skip); the cache-warming feature owns it.
+The backend confirmed all six asks (answers in their `frontend-lsp-cwd-handoff.md`, code refs
+`transport-http/src/app.ts` + `session-orchestrator/src/orchestrator.ts`; live-verified): unseen-id
+`GET /cwd`⇒`{cwd:null}` and `GET /lsp`⇒`{cwd:null,servers:[]}` (no 404/500); `PUT /cwd` on a draft id
+upserts by key; **draft cwd carries into turn 1** when `/chat` omits `cwd`; CORS preflight for `PUT` is
+answered; no LSP spawn while `cwd` is null; errors are `{error:string}`. **No backend change needed —
+the draft→first-message cwd path the FE built is fully supported.**
-(The standalone courier `backend-handoff-cache-warming-timer.md` is now historical — no open asks.)
+**FE invariant to KEEP (don't regress):** the chat send must **omit** `cwd` (send `undefined`), never
+`cwd:""`/`cwd:null`. The `/chat` `cwd` field treats any non-`undefined` value as "provided", so a literal
+empty would override the persisted draft cwd. Verified safe today: `chat/store.svelte.ts` builds
+`chat.send` with only `type`/`conversationId`/`message`/`model` — no `cwd` field. (The backend offered to
+harden `/chat` to treat blank as "not provided" if we ever want it — not needed while we omit the field.)
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- `GET /conversations` — conversation list / sidebar (history explorer / switcher); could also expose a
per-conversation "last model" so a reopened tab seeds its model from the server instead of localStorage.
- `POST /conversations/:id/cancel` — "stop generating".
+- **LSP status over WS** (push) — today the FE HTTP-polls `GET /conversations/:id/lsp` on panel mount /
+ cwd change + a manual refresh; a live surface/WS push would remove the manual refresh and reflect a
+ server flipping to `error`/`connected` without a reload. (Backend flagged this as a future option.)
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();
diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts
new file mode 100644
index 0000000..0d30257
--- /dev/null
+++ b/src/features/smart-scroll/index.ts
@@ -0,0 +1,25 @@
+export type {
+ ScrollCommand,
+ ScrollGeometry,
+ SmartScrollResult,
+ SmartScrollState,
+} from "./logic/smart-scroll";
+export {
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+} from "./logic/smart-scroll";
+export type { SmartScrollController } from "./ui/controller.svelte";
+export { createSmartScrollController } from "./ui/controller.svelte";
+export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "smart-scroll",
+ description:
+ "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up",
+} as const;
diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts
new file mode 100644
index 0000000..fc3e3d1
--- /dev/null
+++ b/src/features/smart-scroll/logic/smart-scroll.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import {
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollGeometry,
+} from "./smart-scroll";
+
+// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom.
+const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 };
+const nearBottom: ScrollGeometry = {
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
+ scrollHeight: 1000,
+ clientHeight: 100,
+};
+const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 };
+
+describe("isNearBottom", () => {
+ it("is true exactly at the bottom", () => {
+ expect(isNearBottom(atBottom)).toBe(true);
+ });
+
+ it("is true within the threshold of the bottom", () => {
+ expect(isNearBottom(nearBottom)).toBe(true);
+ });
+
+ it("is false just beyond the threshold", () => {
+ expect(
+ isNearBottom({
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
+ scrollHeight: 1000,
+ clientHeight: 100,
+ }),
+ ).toBe(false);
+ });
+
+ it("is false when scrolled well up", () => {
+ expect(isNearBottom(scrolledUp)).toBe(false);
+ });
+
+ it("honours a custom threshold", () => {
+ const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
+ expect(isNearBottom(geom, 50)).toBe(false);
+ expect(isNearBottom(geom, 150)).toBe(true);
+ });
+});
+
+describe("smart-scroll reducer", () => {
+ it("starts stuck and hides the button", () => {
+ const s = createSmartScrollState();
+ expect(s.stuck).toBe(true);
+ });
+
+ it("onScroll up unsticks and shows the button, with no command", () => {
+ const r = onScroll(createSmartScrollState(), scrolledUp);
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ expect(r.command).toBeNull();
+ });
+
+ it("onScroll back to the bottom re-sticks and hides the button", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onScroll(up, atBottom);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toBeNull();
+ });
+
+ it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
+ const r = onContentChange(createSmartScrollState(), atBottom);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.state.stuck).toBe(true);
+ });
+
+ it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onContentChange(up, scrolledUp);
+ expect(r.command).toBeNull();
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ });
+
+ it("onResume re-sticks and emits an ANIMATED scroll", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onResume(up);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
+ });
+
+ it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onReset();
+ void up;
+ expect(r.state.stuck).toBe(true);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.showButton).toBe(false);
+ });
+});
diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts
new file mode 100644
index 0000000..021b3fe
--- /dev/null
+++ b/src/features/smart-scroll/logic/smart-scroll.ts
@@ -0,0 +1,93 @@
+// Pure smart-scroll reducer — "stick the transcript to the bottom while it grows,
+// unless the user has scrolled up". Zero DOM, zero Svelte: it takes scroll
+// GEOMETRY snapshots in and returns the next state plus an optional scroll
+// COMMAND for the shell to execute. The injected shell (the Svelte action) reads
+// the geometry off a real element and runs the commands.
+
+/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */
+export interface ScrollGeometry {
+ /** Current scroll offset from the top. */
+ readonly scrollTop: number;
+ /** Total scrollable content height. */
+ readonly scrollHeight: number;
+ /** Visible viewport height. */
+ readonly clientHeight: number;
+}
+
+/** Distance (px) from the bottom within which we still consider the view "at bottom". */
+export const NEAR_BOTTOM_THRESHOLD = 64;
+
+/** True when the viewport is within `threshold` px of the content's bottom edge. */
+export function isNearBottom(
+ geom: ScrollGeometry,
+ threshold: number = NEAR_BOTTOM_THRESHOLD,
+): boolean {
+ return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
+}
+
+/** A scroll the shell should perform on the real element. */
+export interface ScrollCommand {
+ readonly kind: "scroll-to-bottom";
+ /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
+ readonly animate: boolean;
+}
+
+export interface SmartScrollState {
+ /**
+ * Whether the view is currently following the bottom. While `stuck`, new
+ * content keeps the view pinned to the bottom; once the user scrolls up it
+ * goes false and stays false until they return to the bottom (or resume).
+ */
+ readonly stuck: boolean;
+}
+
+/** A reducer step's result: the next state, an optional command, and whether to show the button. */
+export interface SmartScrollResult {
+ readonly state: SmartScrollState;
+ readonly command: ScrollCommand | null;
+ /** Show the "scroll to bottom" affordance exactly when not stuck. */
+ readonly showButton: boolean;
+}
+
+/** Initial state — start stuck so the first content snaps to the bottom. */
+export function createSmartScrollState(): SmartScrollState {
+ return { stuck: true };
+}
+
+function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult {
+ return { state, command, showButton: !state.stuck };
+}
+
+/**
+ * The user scrolled (or the viewport resized). Re-derive `stuck` purely from
+ * geometry: near the bottom ⇒ stuck (follow), otherwise unstuck. Never emits a
+ * command — reacting to the user's own scroll with a scroll would fight them.
+ */
+export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult {
+ return result({ stuck: isNearBottom(geom) }, null);
+}
+
+/**
+ * Content changed (a streamed delta, a new message, history loaded). If we're
+ * stuck, emit a non-animated scroll to keep up; otherwise leave the user where
+ * they are. State is unchanged — content growth alone never flips `stuck`.
+ */
+export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult {
+ return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
+}
+
+/**
+ * The user asked to return to the bottom (clicked the button). Force-stick and
+ * emit an animated scroll.
+ */
+export function onResume(_state: SmartScrollState): SmartScrollResult {
+ return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
+}
+
+/**
+ * The transcript context changed entirely (e.g. a conversation/tab switch).
+ * Reset to stuck and snap (non-animated) to the bottom of the new content.
+ */
+export function onReset(): SmartScrollResult {
+ return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
+}
diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte
new file mode 100644
index 0000000..6fbd326
--- /dev/null
+++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ // Thin affordance: a floating "scroll to bottom" button shown while the reader
+ // has scrolled up. Holds no logic — `show` and `onResume` come from the
+ // smart-scroll controller.
+ let {
+ show,
+ onResume,
+ }: {
+ show: boolean;
+ onResume: () => void;
+ } = $props();
+</script>
+
+<button
+ type="button"
+ class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
+ class:opacity-0={!show}
+ class:pointer-events-none={!show}
+ class:opacity-100={show}
+ onclick={onResume}
+ aria-label="Scroll to bottom"
+ aria-hidden={!show}
+ tabindex={show ? 0 : -1}
+>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
+ </svg>
+</button>
diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts
new file mode 100644
index 0000000..99d53ca
--- /dev/null
+++ b/src/features/smart-scroll/ui/controller.svelte.ts
@@ -0,0 +1,130 @@
+// Injected shell for smart-scroll: binds a real scrollable element to the pure
+// reducer (logic/smart-scroll). It owns the reactive `showButton` flag (a thin
+// rune wrapper over the reducer state), runs the scroll COMMANDS the reducer
+// emits against the element, and listens at the outermost edges (the element's
+// `scroll`/`scrollend` events + a ResizeObserver on the content). No ambient
+// state: the consumer instantiates ONE controller per scroll region and disposes
+// it on unmount.
+
+import {
+ createSmartScrollState,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollCommand,
+ type ScrollGeometry,
+ type SmartScrollResult,
+ type SmartScrollState,
+} from "../logic/smart-scroll";
+
+export interface SmartScrollController {
+ /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
+ readonly showButton: boolean;
+ /**
+ * Attach to the scroll container; returns a teardown to call on unmount.
+ * Pass the inner CONTENT element to also follow height changes that aren't a
+ * transcript update (async markdown/highlight, image loads, a collapse toggling,
+ * viewport reflow) via a ResizeObserver.
+ */
+ attach(el: HTMLElement, content?: HTMLElement): () => void;
+ /**
+ * Notify that the transcript content changed (a streamed delta / new message).
+ * While stuck, keeps the view pinned to the bottom.
+ */
+ contentChanged(): void;
+ /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
+ reset(): void;
+ /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
+ resume(): void;
+}
+
+function geometryOf(el: HTMLElement): ScrollGeometry {
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ };
+}
+
+export function createSmartScrollController(): SmartScrollController {
+ let state: SmartScrollState = createSmartScrollState();
+ let showButton = $state(false);
+ let el: HTMLElement | null = null;
+ // True while WE drive a programmatic scroll, so the resulting `scroll` event
+ // doesn't get misread as the user scrolling up. Cleared on `scrollend`.
+ let selfScrolling = false;
+
+ function run(command: ScrollCommand | null): void {
+ if (!command || !el) return;
+ selfScrolling = true;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: command.animate ? "smooth" : "instant",
+ });
+ }
+
+ function apply(r: SmartScrollResult): void {
+ state = r.state;
+ showButton = r.showButton;
+ run(r.command);
+ }
+
+ function handleScroll(): void {
+ if (!el || selfScrolling) return;
+ apply(onScroll(state, geometryOf(el)));
+ }
+
+ function handleScrollEnd(): void {
+ selfScrolling = false;
+ }
+
+ return {
+ get showButton(): boolean {
+ return showButton;
+ },
+
+ attach(node: HTMLElement, content?: HTMLElement): () => void {
+ el = node;
+ node.addEventListener("scroll", handleScroll, { passive: true });
+ node.addEventListener("scrollend", handleScrollEnd);
+
+ // A ResizeObserver keeps the view pinned through height changes that are
+ // NOT a transcript update — async markdown/syntax-highlight, image loads, a
+ // collapse toggling, font swaps, viewport reflow — which a content-count
+ // signal can't see. Observe the CONTENT (it grows) and the container (it
+ // changes on viewport resize). Routed through `onContentChange`, so it only
+ // scrolls while stuck and never fights the reader. The `selfScrolling` guard
+ // (and the fact that scrolling doesn't resize content) prevents any loop.
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(() => {
+ if (!el || selfScrolling) return;
+ apply(onContentChange(state, geometryOf(el)));
+ });
+ if (content) ro.observe(content);
+ ro.observe(node);
+ }
+
+ return () => {
+ node.removeEventListener("scroll", handleScroll);
+ node.removeEventListener("scrollend", handleScrollEnd);
+ ro?.disconnect();
+ if (el === node) el = null;
+ };
+ },
+
+ contentChanged(): void {
+ if (!el) return;
+ apply(onContentChange(state, geometryOf(el)));
+ },
+
+ reset(): void {
+ apply(onReset());
+ },
+
+ resume(): void {
+ apply(onResume(state));
+ },
+ };
+}
diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts
new file mode 100644
index 0000000..614f4b0
--- /dev/null
+++ b/src/features/smart-scroll/ui/controller.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it, vi } from "vitest";
+import { createSmartScrollController } from "./controller.svelte";
+
+// A minimal fake of the only DOM surface the controller touches: scroll
+// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend".
+// Faking this outermost edge is the sanctioned mock (no internal modules mocked).
+function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) {
+ const listeners = new Map<string, Set<EventListener>>();
+ const el = {
+ scrollTop: 0,
+ scrollHeight: opts?.scrollHeight ?? 1000,
+ clientHeight: opts?.clientHeight ?? 100,
+ scrollTo: vi.fn((arg: ScrollToOptions) => {
+ // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
+ el.scrollTop = (arg.top ?? 0) - 0;
+ if (arg.behavior !== "smooth") {
+ fire("scroll");
+ fire("scrollend");
+ }
+ }),
+ addEventListener: (type: string, fn: EventListener) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type)?.add(fn);
+ },
+ removeEventListener: (type: string, fn: EventListener) => {
+ listeners.get(type)?.delete(fn);
+ },
+ };
+ function fire(type: string): void {
+ for (const fn of listeners.get(type) ?? []) fn(new Event(type));
+ }
+ // Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
+ function userScrollTo(top: number): void {
+ el.scrollTop = top;
+ fire("scroll");
+ }
+ return {
+ el: el as unknown as HTMLElement,
+ scrollTo: el.scrollTo,
+ fire,
+ userScrollTo,
+ listenerCount: () => listeners,
+ };
+}
+
+describe("smart-scroll controller", () => {
+ it("starts with the button hidden", () => {
+ const c = createSmartScrollController();
+ expect(c.showButton).toBe(false);
+ });
+
+ it("contentChanged while stuck scrolls to the bottom instantly", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ c.contentChanged();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
+
+ it("a user scroll up shows the button and stops auto-following", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200); // far from the bottom
+ expect(c.showButton).toBe(true);
+
+ const scrollTo = fake.scrollTo;
+ scrollTo.mockClear();
+ c.contentChanged(); // streaming more content...
+ expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
+ expect(c.showButton).toBe(true);
+ });
+
+ it("self-driven scrolls are not misread as the user scrolling up", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ // contentChanged drives an instant scrollTo, whose synthetic scroll event
+ // must NOT flip us to unstuck (selfScrolling guard).
+ c.contentChanged();
+ expect(c.showButton).toBe(false);
+ });
+
+ it("resume re-sticks and smooth-scrolls to the bottom", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
+
+ c.resume();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "smooth",
+ });
+ expect(c.showButton).toBe(false);
+ });
+
+ it("reset snaps to the bottom and hides the button", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
+ c.reset();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
+
+ it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
+ const holder: { cb: ResizeObserverCallback | null } = { cb: null };
+ const observed: unknown[] = [];
+ const disconnect = vi.fn();
+ class FakeResizeObserver {
+ constructor(cb: ResizeObserverCallback) {
+ holder.cb = cb;
+ }
+ observe(target: Element): void {
+ observed.push(target);
+ }
+ unobserve(): void {}
+ disconnect = disconnect;
+ }
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ try {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const content = { id: "content" } as unknown as HTMLElement;
+ const teardown = c.attach(fake.el, content);
+
+ // Observes both the content (it grows) and the scroll container (viewport resize).
+ expect(observed).toContain(content);
+ expect(observed).toContain(fake.el);
+
+ // Stuck → a resize keeps us pinned to the bottom.
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });
+
+ // Reader scrolls up → a later resize must NOT yank them down.
+ fake.userScrollTo(200);
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).not.toHaveBeenCalled();
+
+ // Teardown disconnects the observer.
+ teardown();
+ expect(disconnect).toHaveBeenCalled();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("attach returns a teardown that removes both listeners", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const teardown = c.attach(fake.el);
+ const before = fake.listenerCount();
+ expect(before.get("scroll")?.size).toBe(1);
+ expect(before.get("scrollend")?.size).toBe(1);
+ teardown();
+ expect(before.get("scroll")?.size).toBe(0);
+ expect(before.get("scrollend")?.size).toBe(0);
+ });
+});
diff --git a/src/features/workspace/index.ts b/src/features/workspace/index.ts
new file mode 100644
index 0000000..9acf994
--- /dev/null
+++ b/src/features/workspace/index.ts
@@ -0,0 +1,14 @@
+export type {
+ CwdSaveResult,
+ LoadLspStatus,
+ LspStatusResult,
+ SaveCwd,
+} from "./logic/view-model";
+export { default as CwdField } from "./ui/CwdField.svelte";
+export { default as LspStatusView } from "./ui/LspStatusView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "workspace",
+ description: "Per-conversation working directory + language-server status",
+} as const;
diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/workspace/logic/view-model.test.ts
new file mode 100644
index 0000000..a06edeb
--- /dev/null
+++ b/src/features/workspace/logic/view-model.test.ts
@@ -0,0 +1,101 @@
+import type { LspServerInfo } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ cwdChanged,
+ isSubmittableCwd,
+ normalizeCwd,
+ summarizeServers,
+ viewLspServer,
+ viewLspServers,
+} from "./view-model";
+
+const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/me/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected",
+ ...over,
+});
+
+describe("cwd helpers", () => {
+ it("normalizeCwd trims surrounding whitespace", () => {
+ expect(normalizeCwd(" /a/b ")).toBe("/a/b");
+ expect(normalizeCwd("\t/x\n")).toBe("/x");
+ });
+
+ it("isSubmittableCwd is false for empty / whitespace-only", () => {
+ expect(isSubmittableCwd("")).toBe(false);
+ expect(isSubmittableCwd(" ")).toBe(false);
+ expect(isSubmittableCwd("/a")).toBe(true);
+ });
+
+ it("cwdChanged: true only when a non-empty trimmed value differs from current", () => {
+ expect(cwdChanged("/a/b", null)).toBe(true);
+ expect(cwdChanged("/a/b", "/a/b")).toBe(false);
+ expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change
+ expect(cwdChanged("/a/c", "/a/b")).toBe(true);
+ expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear)
+ expect(cwdChanged(" ", null)).toBe(false);
+ });
+});
+
+describe("viewLspServer", () => {
+ it("connected → success badge, not busy, no error", () => {
+ const v = viewLspServer(server({ state: "connected" }));
+ expect(v.badge).toBe("success");
+ expect(v.statusLabel).toBe("Connected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ expect(v.extensionsLabel).toBe(".ts .tsx");
+ });
+
+ it("starting / not-started → busy (spinner) with warning / neutral badge", () => {
+ const starting = viewLspServer(server({ state: "starting" }));
+ expect(starting.badge).toBe("warning");
+ expect(starting.busy).toBe(true);
+
+ const notStarted = viewLspServer(server({ state: "not-started" }));
+ expect(notStarted.badge).toBe("neutral");
+ expect(notStarted.busy).toBe(true);
+ });
+
+ it("error → error badge + surfaces the reason (with a fallback)", () => {
+ const withReason = viewLspServer(server({ state: "error", error: "ENOENT" }));
+ expect(withReason.badge).toBe("error");
+ expect(withReason.busy).toBe(false);
+ expect(withReason.error).toBe("ENOENT");
+
+ const noReason = viewLspServer(server({ state: "error" }));
+ expect(noReason.error).toBe("Failed to start");
+ });
+
+ it("viewLspServers maps a list preserving order", () => {
+ const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("summarizeServers", () => {
+ it("empty list", () => {
+ expect(summarizeServers([])).toBe("No language servers");
+ });
+
+ it("counts connected / starting / errors", () => {
+ expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected");
+ expect(
+ summarizeServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 error");
+ expect(
+ summarizeServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "starting" }),
+ server({ id: "c", state: "error" }),
+ server({ id: "d", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 starting, 2 errors");
+ });
+});
diff --git a/src/features/workspace/logic/view-model.ts b/src/features/workspace/logic/view-model.ts
new file mode 100644
index 0000000..bc9b30b
--- /dev/null
+++ b/src/features/workspace/logic/view-model.ts
@@ -0,0 +1,130 @@
+import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The workspace feature exposes a conversation's per-tab working directory (cwd)
+ * and the live status of the language servers configured for that cwd. This
+ * module holds the pure logic: cwd normalization/validation, the mapping of a
+ * backend `LspServerState` to a display badge, and a one-line server summary.
+ * The effects (the HTTP get/set cwd + get LSP status) are INJECTED via the ports
+ * below; the composition root implements them.
+ */
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */
+export type CwdSaveResult =
+ | { readonly ok: true; readonly cwd: string | null }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveCwd = (cwd: string) => Promise<CwdSaveResult | null>;
+
+/** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */
+export type LspStatusResult =
+ | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadLspStatus = () => Promise<LspStatusResult | null>;
+
+// ── cwd helpers ───────────────────────────────────────────────────────────────
+
+/** Trim surrounding whitespace; the backend rejects an empty cwd. */
+export function normalizeCwd(raw: string): string {
+ return raw.trim();
+}
+
+/** Whether a typed cwd is submittable (non-empty after trim). */
+export function isSubmittableCwd(raw: string): boolean {
+ return normalizeCwd(raw).length > 0;
+}
+
+/**
+ * Whether saving `typed` would change the persisted `current` cwd. A no-op save
+ * (unchanged, or empty) should be disabled.
+ */
+export function cwdChanged(typed: string, current: string | null): boolean {
+ const next = normalizeCwd(typed);
+ if (next.length === 0) return false;
+ return next !== (current ?? "");
+}
+
+// ── LSP server status → display view ──────────────────────────────────────────
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+export interface LspServerView {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ /** Space-joined extension list, e.g. ".ts .tsx". */
+ readonly extensionsLabel: string;
+ readonly state: LspServerState;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the state is transient (show a spinner). */
+ readonly busy: boolean;
+ /** The error reason when `state === "error"`, else null. */
+ readonly error: string | null;
+}
+
+/** Map a server's state to a display label + badge severity + busy flag. */
+export function viewLspServer(server: LspServerInfo): LspServerView {
+ let statusLabel: string;
+ let badge: Badge;
+ let busy = false;
+ switch (server.state) {
+ case "connected":
+ statusLabel = "Connected";
+ badge = "success";
+ break;
+ case "starting":
+ statusLabel = "Starting…";
+ badge = "warning";
+ busy = true;
+ break;
+ case "not-started":
+ statusLabel = "Not started";
+ badge = "neutral";
+ busy = true;
+ break;
+ case "error":
+ statusLabel = "Error";
+ badge = "error";
+ break;
+ }
+ return {
+ id: server.id,
+ name: server.name,
+ root: server.root,
+ extensionsLabel: server.extensions.join(" "),
+ state: server.state,
+ statusLabel,
+ badge,
+ busy,
+ error: server.state === "error" ? (server.error ?? "Failed to start") : null,
+ };
+}
+
+export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] {
+ return servers.map(viewLspServer);
+}
+
+/** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */
+export function summarizeServers(servers: readonly LspServerInfo[]): string {
+ if (servers.length === 0) return "No language servers";
+ let connected = 0;
+ let errored = 0;
+ let pending = 0;
+ for (const s of servers) {
+ if (s.state === "connected") connected++;
+ else if (s.state === "error") errored++;
+ else pending++;
+ }
+ const parts: string[] = [];
+ if (connected > 0) parts.push(`${connected} connected`);
+ if (pending > 0) parts.push(`${pending} starting`);
+ if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
+ return parts.join(", ");
+}
diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/workspace/ui/CwdField.svelte
new file mode 100644
index 0000000..bd8b870
--- /dev/null
+++ b/src/features/workspace/ui/CwdField.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model";
+
+ let {
+ cwd,
+ canEdit,
+ save,
+ }: {
+ /** The active conversation's persisted cwd, or null when unset. */
+ cwd: string | null;
+ /** Whether a real conversation is focused (a draft can't persist a cwd yet). */
+ canEdit: boolean;
+ save: SaveCwd;
+ } = $props();
+
+ // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading
+ // the prop directly into initial $state would only capture its first value.)
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ // Seed the input from the persisted cwd (it loads async). Only reseed while the
+ // field is untouched, so an in-flight load can't clobber what the user typed.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ $effect(() => {
+ const incoming = cwd ?? "";
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const dirty = $derived(cwdChanged(value, cwd));
+
+ async function handleSave() {
+ if (saving || !canEdit || !dirty) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(normalizeCwd(value));
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
+</script>
+
+<div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Working directory</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"}
+ bind:value
+ disabled={!canEdit || saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Working directory"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={!canEdit || saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if !canEdit}
+ <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved.</p>
+ {:else}
+ <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p>
+ {/if}
+</div>
diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/workspace/ui/LspStatusView.svelte
new file mode 100644
index 0000000..77603a1
--- /dev/null
+++ b/src/features/workspace/ui/LspStatusView.svelte
@@ -0,0 +1,127 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadLspStatus,
+ type LspServerView,
+ summarizeServers,
+ viewLspServers,
+ } from "../logic/view-model";
+
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadLspStatus;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ let servers = $state<readonly LspServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
+
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewLspServers(result.servers);
+ summary = summarizeServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
+
+ // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET
+ // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to
+ // the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
+</script>
+
+<div class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ Language servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh language server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable language servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No language servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium">{server.name}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ {#if server.extensionsLabel}
+ <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span>
+ {/if}
+ <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span>
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
+</div>
diff --git a/vitest-setup.ts b/vitest-setup.ts
index 2c29eea..10a3160 100644
--- a/vitest-setup.ts
+++ b/vitest-setup.ts
@@ -1,2 +1,16 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
+
+// jsdom implements neither Element.scrollTo nor ResizeObserver; the smart-scroll
+// controller uses both against the real transcript element when App mounts. Stub
+// the outermost edges so component tests can render without throwing.
+if (typeof Element !== "undefined" && typeof Element.prototype.scrollTo !== "function") {
+ Element.prototype.scrollTo = () => {};
+}
+if (typeof globalThis.ResizeObserver === "undefined") {
+ globalThis.ResizeObserver = class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ };
+}