summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspace
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
committerAdam Malczewski <[email protected]>2026-06-12 00:22:42 +0900
commitfd565a6555e8bc9f37f21cf9d900523ef3be531b (patch)
treeecf2c365c0c5e0ccdfc1a9ae350af933e4860ed2 /src/features/workspace
parente45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (diff)
downloaddispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.tar.gz
dispatch-web-fd565a6555e8bc9f37f21cf9d900523ef3be531b.zip
feat(workspace,smart-scroll): per-conversation cwd + LSP view; smart auto-scroll
workspace ([email protected]): a cwd field in the Model sidebar view (GET/PUT /conversations/:id/cwd) + a new 'Language Servers' view (GET /conversations/:id/lsp) with per-server connected/starting/error badges, spinner, error text, and refresh. Store-owned reactive cwd, re-seeded on focus change; works for DRAFTS too (targets the draft's client-minted id, which survives promotion, so turn 1 runs in the chosen cwd). Network seam normalizes the untyped LSP body. smart-scroll: pure stick-to-bottom reducer + injected controller shell (scroll/scrollend + a ResizeObserver on the content so the view follows async height changes — markdown/highlight, images, collapses, viewport reflow), plus a floating scroll-to-bottom button. FIX: restore the transcript scrollbar — the refactor moved overflow-y-auto to an inner child, so the flex-1 container needed min-h-0 to constrain instead of growing to content. harness: vitest-setup polyfills Element.scrollTo + ResizeObserver (jsdom implements neither), fixing App component tests. docs: backend-handoff pruned (CR-3 resolved/removed); added cwd/LSP verification courier (backend confirmed all 6 asks ✅); removed the resolved cache-warming-timer courier. Verified: svelte-check 0 errors, biome clean, 523 tests pass, vite build OK.
Diffstat (limited to 'src/features/workspace')
-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
5 files changed, 468 insertions, 0 deletions
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>