From 38db3827870960f466be89afbc49f91238d46144 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Thu, 25 Jun 2026 10:55:51 +0900
Subject: feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt
 features + app wiring

- workspaces: URL-driven conversation grouping (home listing at /, routing,
  store, http adapter, WorkspaceCard) wired into the App.svelte shell
- rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature)
- new features: mcp (status view), settings (chat-limit field), system-prompt
  (prompt builder), all rendered via the generic surface host
- chat: store + ChatView updates
- tabs: tabs-store updates
- app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte

This commit makes HEAD typecheck clean for the first time: the prior HEAD
(c95cc77) imported features/settings from app/App.svelte but never committed
the feature, so only the full working tree was green.
---
 src/features/workspaces/ui/WorkspaceCard.svelte | 180 ++++++++++++++++++++++++
 1 file changed, 180 insertions(+)
 create mode 100644 src/features/workspaces/ui/WorkspaceCard.svelte

(limited to 'src/features/workspaces/ui/WorkspaceCard.svelte')

diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
new file mode 100644
index 0000000..6348bb9
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -0,0 +1,180 @@
+<script lang="ts">
+	import type { WorkspaceEntry } from "@dispatch/wire";
+	import { untrack } from "svelte";
+	import type { WorkspaceStore } from "../store.svelte";
+	import { relativeTime } from "../logic/view-model";
+	import { workspacePath } from "../logic/route";
+
+	let {
+		ws,
+		store,
+		onNavigate,
+	}: {
+		ws: WorkspaceEntry;
+		store: WorkspaceStore;
+		onNavigate: (path: string) => void;
+	} = $props();
+
+	// ── Title: double-click to rename inline ──────────────────────────────────
+	let editingTitle = $state(false);
+	let titleDraft = $state("");
+	let titleInput = $state<HTMLInputElement | undefined>();
+	let titleError = $state<string | null>(null);
+
+	function startEditTitle(): void {
+		titleDraft = ws.title;
+		titleError = null;
+		editingTitle = true;
+		queueMicrotask(() => titleInput?.focus());
+	}
+
+	async function saveTitle(): Promise<void> {
+		if (!editingTitle) return;
+		const title = titleDraft.trim();
+		editingTitle = false;
+		if (title === "" || title === ws.title) return;
+		const result = await store.rename(ws.id, title);
+		if (!result.ok) titleError = result.error;
+	}
+
+	function cancelTitle(): void {
+		editingTitle = false;
+		titleError = null;
+	}
+
+	// ── Default cwd: inline input ─────────────────────────────────────────────
+	let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
+	// Reseed when the backend value changes (e.g., after a save or an external
+	// refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
+	$effect(() => {
+		cwdDraft = ws.defaultCwd ?? "";
+	});
+
+	const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
+	let savingCwd = $state(false);
+	let cwdError = $state<string | null>(null);
+
+	async function saveCwd(): Promise<void> {
+		if (!cwdDirty || savingCwd) return;
+		savingCwd = true;
+		cwdError = null;
+		const cwd = cwdDraft.trim();
+		const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
+		savingCwd = false;
+		if (!result.ok) cwdError = result.error;
+	}
+
+	// ── Delete ─────────────────────────────────────────────────────────────────
+	let deleting = $state(false);
+
+	async function handleDelete(): Promise<void> {
+		if (
+			!window.confirm(
+				`Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+			)
+		) {
+			return;
+		}
+		deleting = true;
+		await store.remove(ws.id);
+		deleting = false;
+	}
+
+	function open(): void {
+		onNavigate(workspacePath(ws.id));
+	}
+</script>
+
+<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3">
+	<div class="flex items-center gap-2">
+		{#if editingTitle}
+			<input
+				bind:this={titleInput}
+				bind:value={titleDraft}
+				class="input input-bordered input-sm flex-1"
+				aria-label="Workspace title"
+				onkeydown={(e) => {
+					if (e.key === "Enter") saveTitle();
+					else if (e.key === "Escape") cancelTitle();
+				}}
+				onblur={saveTitle}
+			/>
+		{:else}
+			<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+			<span
+				class="flex-1 cursor-default truncate font-semibold"
+				title="Double-click to rename"
+				ondblclick={startEditTitle}
+			>{ws.title}</span>
+		{/if}
+		<span class="font-mono text-xs opacity-50">/{ws.id}</span>
+		<span class="ml-auto text-xs opacity-50">
+			{ws.conversationCount}
+			{ws.conversationCount === 1 ? "conversation" : "conversations"}
+			· {relativeTime(ws.lastActivityAt, Date.now())}
+		</span>
+		<button
+			type="button"
+			class="btn btn-ghost btn-xs"
+			disabled={deleting}
+			title="Delete workspace"
+			aria-label="Delete workspace"
+			onclick={handleDelete}
+		>
+			{#if deleting}
+				<span class="loading loading-spinner loading-xs"></span>
+			{:else}
+				✕
+			{/if}
+		</button>
+	</div>
+
+	{#if titleError}
+		<p class="text-xs text-error">{titleError}</p>
+	{/if}
+
+	<div class="flex items-center gap-2">
+		<span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
+		<input
+			type="text"
+			class="input input-bordered input-sm flex-1 font-mono text-xs"
+			placeholder="inherits the server default"
+			bind:value={cwdDraft}
+			aria-label="Default working directory"
+			onkeydown={(e) => {
+				if (e.key === "Enter") saveCwd();
+			}}
+		/>
+		<button
+			type="button"
+			class="btn btn-primary btn-xs"
+			disabled={!cwdDirty || savingCwd}
+			onclick={saveCwd}
+		>
+			{#if savingCwd}
+				<span class="loading loading-spinner loading-xs"></span>
+			{:else}
+				Set
+			{/if}
+		</button>
+	</div>
+
+	<div class="flex justify-start">
+		<a
+			class="btn"
+			href={workspacePath(ws.id)}
+			onclick={(e) => {
+				e.preventDefault();
+				open();
+			}}
+		>
+			Open
+		</a>
+	</div>
+
+	{#if cwdError}
+		<p class="text-xs text-error">{cwdError}</p>
+	{:else if !cwdDirty && !ws.defaultCwd}
+		<p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
+	{/if}
+</li>
-- 
cgit v1.2.3

