diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 59 | ||||
| -rw-r--r-- | backend-handoff-cache-warming-timer.md | 80 | ||||
| -rw-r--r-- | backend-handoff-cwd-lsp.md | 61 | ||||
| -rw-r--r-- | backend-handoff.md | 60 | ||||
| -rw-r--r-- | src/app/App.svelte | 90 | ||||
| -rw-r--r-- | src/app/App.test.ts | 8 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 113 | ||||
| -rw-r--r-- | src/features/smart-scroll/index.ts | 25 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.test.ts | 103 | ||||
| -rw-r--r-- | src/features/smart-scroll/logic/smart-scroll.ts | 93 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/ScrollToBottom.svelte | 36 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.svelte.ts | 130 | ||||
| -rw-r--r-- | src/features/smart-scroll/ui/controller.test.ts | 172 | ||||
| -rw-r--r-- | src/features/workspace/index.ts | 14 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.test.ts | 101 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.ts | 130 | ||||
| -rw-r--r-- | src/features/workspace/ui/CwdField.svelte | 96 | ||||
| -rw-r--r-- | src/features/workspace/ui/LspStatusView.svelte | 127 | ||||
| -rw-r--r-- | vitest-setup.ts | 14 |
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` +> (`[email protected]`). + +## 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) | 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 {} + }; +} |
