summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
committerAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
commit754450b539818f9ad94c9586888973b0edd74b68 (patch)
treed852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui
parent719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff)
downloaddispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz
dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid, replacing the max-w-3xl vertical card list. Pure core (zero DOM/Svelte/fetch, fully unit-tested): - logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed ~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns, row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform (greedy first-fit in fill order, hole-free, render-clamped per geometry); firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/ sortByRecentActivity/sanitizeLayout. Persisted data is orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} — order + spans only, never positions; the same data renders in either geometry (spans clamp for the render, persisted values untouched). - logic/drag-controller.ts — pure pointer/press state machine: mouse threshold-drag + tap-open; touch long-press lifts + selects, pre-lift movement cancels so vertical scrolling never fights the gesture. Shell: - tile-layout-store.svelte.ts — thin reactive wrapper persisting through the injected createLocalStore adapter (dispatch.workspaces.tiles), ops: sync/resize/move/placeFirstEmpty/sortByRecentActivity. - ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll + snap + wheel pan + pan arrow + scroll indicator; tall: native vertical scroll), FLIP-animated reflow, drag ghost, transient resize preview. - ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New workspace popover (same slug validation; creates via create-on-miss and does NOT navigate — the new tile lands in the first empty slot in fill order and enters rename mode), global Plugins button, sort-by-recent overflow; compact icon buttons + bottom sheet on tall screens. - ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count, relative last-activity, live active dots, starred tint); inline rename with the old card's semantics. - ui/TileActionBar.svelte — slides up on select (right-click / long-press): Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm + store call preserved). - ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 / tall 2x2) with arrow/Enter/Escape support and ghost preview on commit. - ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd input + default-computer select (same store calls + error states). - WorkspacesHome.svelte orchestrates; App.svelte untouched (same props). - WorkspaceCard.svelte is removed (replaced by the tile system). Manual tile order rules; starred workspaces get a tint (star is no longer a sort key); workspaces unknown to the layout append at the end. vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate stubs — the pointer logic is the outermost edge for the drag tests. Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice), biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte171
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts90
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte307
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts180
-rw-r--r--src/features/workspaces/ui/ResizePicker.svelte107
-rw-r--r--src/features/workspaces/ui/ResizePicker.test.ts88
-rw-r--r--src/features/workspaces/ui/TileActionBar.svelte69
-rw-r--r--src/features/workspaces/ui/TileActionBar.test.ts88
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte319
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts305
-rw-r--r--src/features/workspaces/ui/WorkspaceDetails.svelte132
-rw-r--r--src/features/workspaces/ui/WorkspaceDetails.test.ts114
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.svelte109
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.test.ts157
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte363
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts182
16 files changed, 2064 insertions, 717 deletions
diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte
new file mode 100644
index 0000000..01ac63f
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.svelte
@@ -0,0 +1,171 @@
+<script lang="ts">
+ import { isValidSlug } from "../logic/route";
+
+ /** The create outcome the page reports back to the popover. */
+ export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+ let {
+ compact = false,
+ withPlugins = false,
+ withSort = false,
+ onCreate,
+ onOpenPlugins,
+ onSort,
+ }: {
+ /** Tall geometry: the controls collapse to compact icon buttons and the
+ * new-workspace popover becomes a bottom sheet. */
+ compact?: boolean;
+ /** The plugins port is present → the Plugins button renders. */
+ withPlugins?: boolean;
+ /** The Sort overflow action renders (recent-activity re-apply). */
+ withSort?: boolean;
+ /** Create the workspace on the backend (does NOT navigate); the page
+ * places the new tile + enters rename mode. */
+ onCreate: (slug: string) => Promise<CreateResult>;
+ onOpenPlugins: () => void;
+ onSort: () => void;
+ } = $props();
+
+ // ── New workspace popover (today's inline form logic, preserved) ──────────
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
+ let popoverOpen = $state(false);
+ let submitting = $state(false);
+ let slugInput = $state<HTMLInputElement | undefined>();
+
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+
+ function openPopover(): void {
+ popoverOpen = true;
+ slugError = null;
+ queueMicrotask(() => slugInput?.focus());
+ }
+
+ function closePopover(): void {
+ popoverOpen = false;
+ newSlug = "";
+ slugError = null;
+ }
+
+ async function submit(): Promise<void> {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ submitting = true;
+ const result = await onCreate(slug);
+ submitting = false;
+ if (!result.ok) {
+ slugError = result.error;
+ return;
+ }
+ closePopover();
+ }
+</script>
+
+<header class="flex shrink-0 items-center justify-between gap-2 px-6 py-4">
+ <h1 class="text-3xl font-black tracking-tight">Dispatch</h1>
+
+ <div class="flex items-center gap-2" class:gap-1={compact}>
+ <!-- New workspace -->
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ class:btn-circle={compact}
+ aria-label="New workspace"
+ title="New workspace"
+ onclick={() => (popoverOpen ? closePopover() : openPopover())}
+ >
+ {#if compact}
+ <span aria-hidden="true" class="text-lg leading-none">+</span>
+ {:else}
+ New workspace
+ {/if}
+ </button>
+
+ {#if withPlugins}
+ <button
+ type="button"
+ class="btn btn-outline btn-sm"
+ class:btn-circle={compact}
+ aria-label="Plugins"
+ title="Open the plugin manager (load/unload plugins globally)"
+ onclick={onOpenPlugins}
+ >
+ {#if compact}
+ <span aria-hidden="true">⚡</span>
+ {:else}
+ Plugins
+ {/if}
+ </button>
+ {/if}
+
+ {#if withSort}
+ <div class="dropdown dropdown-end">
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="More actions" title="More">
+ ⋯
+ </button>
+ <ul class="dropdown-content menu z-50 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl">
+ <li>
+ <button type="button" onclick={onSort}>Sort by recent activity</button>
+ </li>
+ </ul>
+ </div>
+ {/if}
+ </div>
+</header>
+
+{#if popoverOpen}
+ <!-- Backdrop closes the popover. -->
+ <button
+ type="button"
+ class="fixed inset-0 z-40 cursor-default"
+ aria-label="Close new workspace"
+ onclick={closePopover}></button>
+
+ <!-- One popover body; the geometry only changes its positioning (bottom
+ sheet on tall screens, a corner card on wide ones). -->
+ <div
+ class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl"
+ class:inset-x-0={compact}
+ class:bottom-0={compact}
+ class:rounded-t-box={compact}
+ class:top-14={!compact}
+ class:right-6={!compact}
+ class:w-80={!compact}
+ class:rounded-box={!compact}
+ >
+ <form
+ class="flex flex-col gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ >
+ <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label>
+ <input
+ id="new-ws"
+ bind:this={slugInput}
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if}
+ <div class="flex justify-end gap-2">
+ {#if compact}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button>
+ {/if}
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}>
+ {#if submitting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Create
+ </button>
+ </div>
+ </form>
+ </div>
+{/if}
diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts
new file mode 100644
index 0000000..a0afc52
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.test.ts
@@ -0,0 +1,90 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import HomeHeader from "./HomeHeader.svelte";
+
+function props() {
+ return {
+ onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })),
+ onOpenPlugins: vi.fn(),
+ onSort: vi.fn(),
+ };
+}
+
+describe("HomeHeader", () => {
+ it("renders the display title and the primary New workspace button", () => {
+ const p = props();
+ render(HomeHeader, { props: p });
+ expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument();
+ });
+
+ it("the Plugins button renders only with the plugins port", () => {
+ const p = props();
+ const without = render(HomeHeader, { props: p });
+ expect(without.queryByRole("button", { name: "Plugins" })).toBeNull();
+ without.unmount();
+
+ render(HomeHeader, { props: { ...p, withPlugins: true } });
+ screen.getByRole("button", { name: "Plugins" }).click();
+ expect(p.onOpenPlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Sort overflow renders only when asked, and dispatches", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: { ...p, withSort: true } });
+
+ await user.click(screen.getByRole("button", { name: "More actions" }));
+ await user.click(screen.getByRole("button", { name: "Sort by recent activity" }));
+ expect(p.onSort).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Create button disables for an invalid slug (validation before submit)", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ const input = screen.getByRole("textbox", { name: "New workspace" });
+ await user.type(input, "Invalid Slug!");
+ expect(screen.getByRole("button", { name: "Create" })).toBeDisabled();
+ expect(p.onCreate).not.toHaveBeenCalled();
+
+ // A valid slug enables it.
+ await user.clear(input);
+ await user.type(input, "my-workspace");
+ expect(screen.getByRole("button", { name: "Create" })).toBeEnabled();
+ });
+
+ it("submitting the form with Enter creates via the callback and closes the popover", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}");
+
+ expect(p.onCreate).toHaveBeenCalledWith("my-workspace");
+ });
+
+ it("a backend error keeps the form open with the message", async () => {
+ const user = userEvent.setup();
+ const onCreate = vi.fn(
+ async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }),
+ );
+ render(HomeHeader, { props: { ...props(), onCreate } });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ expect(await screen.findByText("taken")).toBeInTheDocument();
+ });
+
+ it("compact mode collapses the primary button to an icon", () => {
+ const p = props();
+ render(HomeHeader, { props: { ...p, compact: true } });
+ expect(screen.getByRole("button", { name: "New workspace" })).toHaveTextContent("+");
+ });
+});
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte
new file mode 100644
index 0000000..e29b9b1
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.svelte
@@ -0,0 +1,307 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
+ import type { GridGeometry, Placement, Span } from "../logic/tile-layout";
+ import type { WorkspaceStore } from "../store.svelte";
+ import WorkspaceTile from "./WorkspaceTile.svelte";
+
+ let {
+ geometry,
+ placements,
+ workspacesById,
+ store,
+ selectedId = null,
+ renamingId = null,
+ preview = null,
+ hasActive,
+ onOpen,
+ onSelect,
+ onReorder,
+ onRenameEnd,
+ }: {
+ geometry: GridGeometry;
+ /** Packed placements (in pack order — the manual tile order). */
+ placements: readonly Placement[];
+ workspacesById: ReadonlyMap<string, WorkspaceEntry>;
+ /** Forwarded to tiles for the inline rename (semantics preserved). */
+ store: WorkspaceStore;
+ selectedId?: string | null;
+ renamingId?: string | null;
+ /** The resize picker's transient ghost footprint for this tile. */
+ preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null;
+ /** Optional port: does the workspace have an active (generating) conversation. */
+ hasActive?: (workspaceId: string) => boolean;
+ onOpen: (id: string) => void;
+ onSelect: (id: string) => void;
+ /** The drag dropped `id` at `toIndex` in the manual order (-1 = nowhere). */
+ onReorder: (id: string, toIndex: number) => void;
+ onRenameEnd: () => void;
+ } = $props();
+
+ // ── Drag/press state machine (pure logic, wired to pointer events) ────────
+ const DRAG_THRESHOLD_PX = 6;
+ const LONG_PRESS_MS = 500;
+ let phase = $state<DragPhase>({ kind: "idle" });
+ let longPressTimer: ReturnType<typeof setTimeout> | undefined;
+
+ function armLongPress(): void {
+ disarmLongPress();
+ longPressTimer = setTimeout(() => {
+ longPressTimer = undefined;
+ dispatch({ type: "long-press" });
+ }, LONG_PRESS_MS);
+ }
+
+ function disarmLongPress(): void {
+ if (longPressTimer !== undefined) {
+ clearTimeout(longPressTimer);
+ longPressTimer = undefined;
+ }
+ }
+
+ function dispatch(event: DragEvent): void {
+ const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX });
+ phase = transition.phase;
+ const emit = transition.emit;
+ if (emit) {
+ if (emit.kind === "tap") onOpen(emit.id);
+ else if (emit.kind === "lift") onSelect(emit.id);
+ else if (emit.kind === "drop" && emit.targetIndex >= 0) onReorder(emit.id, emit.targetIndex);
+ }
+ // Keep the long-press armed exactly while a TOUCH press is undecided.
+ if (phase.kind === "pressing" && phase.touch) armLongPress();
+ else disarmLongPress();
+ }
+
+ /** The hovered tile's index in the manual order (-1 = not over a tile). */
+ function hoveredIndex(x: number, y: number): number {
+ const hit = document.elementFromPoint(x, y)?.closest("[data-tile-id]");
+ const id = hit?.getAttribute("data-tile-id");
+ if (id === null || id === undefined) return -1;
+ return placements.findIndex((placement) => placement.id === id);
+ }
+
+ /** Interactive targets inside a tile (the rename input) own their gestures. */
+ function insideInteractive(target: EventTarget | null): boolean {
+ const el = target instanceof HTMLElement ? target : null;
+ return el?.closest("input, select, textarea, button, a") !== null;
+ }
+
+ function tilePointerDown(e: PointerEvent, id: string): void {
+ if (e.button !== 0 || insideInteractive(e.target)) return;
+ // Capture so fast drags keep the pointer events on this element.
+ (e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
+ dispatch({
+ type: "pointer-down",
+ id,
+ x: e.clientX,
+ y: e.clientY,
+ touch: e.pointerType === "touch",
+ });
+ }
+
+ function tilePointerMove(e: PointerEvent): void {
+ if (phase.kind === "idle" || insideInteractive(e.target)) return;
+ dispatch({
+ type: "pointer-move",
+ x: e.clientX,
+ y: e.clientY,
+ targetIndex: hoveredIndex(e.clientX, e.clientY),
+ });
+ }
+
+ function tilePointerUp(): void {
+ if (phase.kind === "idle") return;
+ dispatch({ type: "pointer-up" });
+ }
+
+ // ── Tile element registry (for the FLIP reflow animation) ─────────────────
+ let tileEls = new Map<string, HTMLElement>();
+ let lastRects = new Map<string, { x: number; y: number }>();
+
+ function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
+ tileEls.set(id, el);
+ return {
+ destroy() {
+ tileEls.delete(id);
+ },
+ };
+ }
+
+ // FLIP: measure after a placements/geometry change and play the inverted
+ // delta — reordering edits the ORDER, the reflow is always derived.
+ $effect(() => {
+ // Track the layout inputs (the DOM reads below are untracked).
+ const ids = new Set(placements.map((placement) => placement.id));
+ void geometry.mode;
+ const rects = new Map<string, { x: number; y: number }>();
+ for (const [id, el] of tileEls) {
+ if (!ids.has(id)) continue;
+ const rect = el.getBoundingClientRect();
+ rects.set(id, { x: rect.left, y: rect.top });
+ }
+ for (const [id, prev] of lastRects) {
+ const next = rects.get(id);
+ const el = tileEls.get(id);
+ if (!next || !el) continue;
+ const dx = prev.x - next.x;
+ const dy = prev.y - next.y;
+ if ((dx !== 0 || dy !== 0) && typeof el.animate === "function") {
+ el.animate(
+ [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
+ { duration: 200, easing: "ease-out" },
+ );
+ }
+ }
+ lastRects = rects;
+ });
+
+ // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ────
+ let scrollerEl = $state<HTMLDivElement | undefined>();
+ let scrollProgress = $state(0);
+ let canPan = $state(false);
+
+ function updateScroll(): void {
+ const el = scrollerEl;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ canPan = geometry.mode === "wide" && max > 1;
+ scrollProgress = max > 0 ? el.scrollLeft / max : 0;
+ }
+
+ $effect(() => {
+ // Re-measure when the geometry flips or the content first renders/changes.
+ void geometry.mode;
+ void placements.length;
+ updateScroll();
+ });
+
+ function handleWheel(e: WheelEvent): void {
+ // Vertical wheel pans the wide canvas horizontally.
+ if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
+ e.preventDefault();
+ scrollerEl?.scrollBy({ left: e.deltaY });
+ }
+
+ function pan(): void {
+ const el = scrollerEl;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ const atEnd = el.scrollLeft >= max - 1;
+ // One viewport sideways; past the end, pan back to the start.
+ el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" });
+ }
+
+ // ── Grid + tile styles (the geometry decides; no breakpoint classes) ──────
+ const gridStyle = $derived(
+ geometry.mode === "wide"
+ ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
+ ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` +
+ ` gap: ${geometry.unit.gap}px; width: max-content;`
+ : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
+ ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` +
+ ` gap: ${geometry.unit.gap}px;`,
+ );
+
+ function spanFor(placement: Placement): Span {
+ if (preview && preview.id === placement.id) {
+ return { colSpan: preview.colSpan, rowSpan: preview.rowSpan };
+ }
+ return { colSpan: placement.colSpan, rowSpan: placement.rowSpan };
+ }
+
+ function wrapperStyle(placement: Placement): string {
+ const span = spanFor(placement);
+ return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`;
+ }
+</script>
+
+<div class="relative min-h-0 flex-1">
+ <div
+ bind:this={scrollerEl}
+ class="h-full overscroll-contain"
+ class:overflow-x-auto={geometry.mode === "wide"}
+ class:overflow-y-auto={geometry.mode === "tall"}
+ style={`scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`}
+ onscroll={updateScroll}
+ onwheel={handleWheel}
+ >
+ <div class="grid" style={gridStyle}>
+ {#each placements as placement (placement.id)}
+ {@const ws = workspacesById.get(placement.id)}
+ {#if ws}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <div
+ data-tile-id={placement.id}
+ class={`flex select-none flex-col overflow-hidden rounded-box border transition-colors ${
+ selectedId === placement.id ? "border-primary/50 ring-2 ring-primary" : ""
+ } ${
+ ws.starred
+ ? "border-primary/40 bg-primary/20"
+ : "border-base-content/10 bg-base-200"
+ } ${phase.kind === "dragging" && phase.id === placement.id ? "opacity-40" : ""}`}
+ style={`${wrapperStyle(placement)} scroll-snap-align: start;`}
+ role="button"
+ tabindex="0"
+ aria-label={`Open workspace ${ws.title}`}
+ use:trackTile={placement.id}
+ onpointerdown={(e) => tilePointerDown(e, placement.id)}
+ onpointermove={tilePointerMove}
+ onpointerup={tilePointerUp}
+ onpointercancel={() => dispatch({ type: "cancel" })}
+ oncontextmenu={(e) => {
+ e.preventDefault();
+ onSelect(placement.id);
+ }}
+ onkeydown={(e) => {
+ if (insideInteractive(e.target)) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onOpen(placement.id);
+ }
+ }}
+ >
+ <WorkspaceTile
+ {ws}
+ {store}
+ {placement}
+ selected={selectedId === placement.id}
+ renaming={renamingId === placement.id}
+ active={hasActive?.(ws.id) ?? false}
+ {onRenameEnd}
+ />
+ </div>
+ {/if}
+ {/each}
+ </div>
+ </div>
+
+ {#if canPan}
+ <!-- Pan arrow + thin scroll-position indicator (wide geometry only). -->
+ <button
+ type="button"
+ class="btn btn-circle btn-sm absolute right-3 bottom-3 z-10 border-base-content/20 bg-base-100/80 shadow-lg"
+ aria-label="Pan right"
+ title="Pan one screen sideways"
+ onclick={pan}
+ >
+ →
+ </button>
+ <div class="absolute inset-x-0 bottom-0 z-10 h-0.5 bg-base-content/10">
+ <div
+ class="h-full bg-primary transition-[width] duration-100"
+ style={`width: ${Math.round(scrollProgress * 100)}%;`}
+ ></div>
+ </div>
+ {/if}
+</div>
+
+{#if phase.kind === "dragging"}
+ <!-- The drag ghost follows the pointer (transient; never a data change). -->
+ <div
+ class="pointer-events-none fixed z-50 rounded-box border border-primary bg-primary/30 px-3 py-2 text-sm font-bold shadow-2xl"
+ style={`left: ${phase.x + 10}px; top: ${phase.y + 10}px;`}
+ >
+ {workspacesById.get(phase.id)?.title ?? phase.id}
+ </div>
+{/if}
diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts
new file mode 100644
index 0000000..3229d4e
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.test.ts
@@ -0,0 +1,180 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { geometryFor, type Placement } from "../logic/tile-layout";
+import type { WorkspaceStore } from "../store.svelte";
+import HomeTileGrid from "./HomeTileGrid.svelte";
+
+const WIDE = geometryFor(1920, 1080);
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "a",
+ title: "Workspace A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 1,
+ ...overrides,
+ };
+}
+
+function placement(id: string, col: number, row: number): Placement {
+ return { id, col, row, colSpan: 1, rowSpan: 1 };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ rename: vi.fn(),
+ } as unknown as WorkspaceStore;
+}
+
+function baseProps() {
+ return {
+ geometry: WIDE,
+ workspacesById: new Map([
+ ["a", ws()],
+ ["b", ws({ id: "b", title: "Workspace B" })],
+ ]),
+ store: fakeStore(),
+ onOpen: vi.fn(),
+ onSelect: vi.fn(),
+ onReorder: vi.fn(),
+ onRenameEnd: vi.fn(),
+ };
+}
+
+describe("HomeTileGrid", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders one tile per placement, positioned by the packed spans", () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: {
+ ...props,
+ placements: [placement("a", 1, 1), placement("b", 2, 1)],
+ },
+ });
+
+ expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument();
+ const wrappers = container.querySelectorAll("[data-tile-id]");
+ expect(wrappers).toHaveLength(2);
+ expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1");
+ expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1");
+ });
+
+ it("a tap (press + release without moving) opens the workspace", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerUp(wrapper);
+
+ expect(props.onOpen).toHaveBeenCalledWith("a");
+ expect(props.onSelect).not.toHaveBeenCalled();
+ });
+
+ it("right-click selects the tile", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.contextMenu(wrapper);
+
+ expect(props.onSelect).toHaveBeenCalledWith("a");
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("dragging shows the transient ghost, and dropping reorders (when over a target)", async () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op),
+ // but the GHOST must appear while dragging and disappear on drop.
+ Object.defineProperty(document, "elementFromPoint", {
+ configurable: true,
+ value: () => null,
+ });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull();
+
+ fireEvent.pointerUp(wrapper);
+ expect(container.querySelector(".pointer-events-none.fixed")).toBeNull();
+ // No hit target → no reorder call.
+ expect(props.onReorder).not.toHaveBeenCalled();
+ // And no navigation either (a drag is not a tap).
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("a touch long-press lifts the tile and selects it (never opens)", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, {
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ pointerType: "touch",
+ });
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(props.onSelect).toHaveBeenCalledWith("a");
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("a touch drag that moves before the long-press cancels (scroll wins)", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, {
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ pointerType: "touch",
+ });
+ fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 });
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(props.onSelect).not.toHaveBeenCalled();
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("previews a transient span for the resizing tile", () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: {
+ ...props,
+ placements: [placement("a", 1, 1)],
+ preview: { id: "a", colSpan: 3, rowSpan: 2 },
+ },
+ });
+
+ const wrapper = container.querySelector("[data-tile-id='a']");
+ expect(wrapper?.getAttribute("style")).toContain("span 3");
+ expect(wrapper?.getAttribute("style")).toContain("span 2");
+ });
+});
diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte
new file mode 100644
index 0000000..e066519
--- /dev/null
+++ b/src/features/workspaces/ui/ResizePicker.svelte
@@ -0,0 +1,107 @@
+<script lang="ts">
+ import { onMount, untrack } from "svelte";
+ import type { GridGeometry, Span } from "../logic/tile-layout";
+
+ let {
+ geometry,
+ initial,
+ onPreview,
+ onCommit,
+ onCancel,
+ }: {
+ geometry: GridGeometry;
+ /** The tile's current (clamped) spans — the picker's starting cell. */
+ initial: Span;
+ /** Transiently ghost-preview the hovered footprint on the real grid. */
+ onPreview: (span: Span | null) => void;
+ onCommit: (colSpan: number, rowSpan: number) => void;
+ onCancel: () => void;
+ } = $props();
+
+ // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2.
+ const cols = $derived(geometry.maxColSpan);
+ const rows = $derived(geometry.maxRowSpan);
+
+ // The currently hovered cell (c, r) — the highlighted rectangle spans
+ // 1..c × 1..r from the picker's top-left (insert-table style). The picker
+ // mounts fresh per resize session, so seeding from the prop is intended.
+ let hovered = $state({
+ colSpan: untrack(() => Math.min(initial.colSpan, cols)),
+ rowSpan: untrack(() => Math.min(initial.rowSpan, rows)),
+ });
+
+ function hover(colSpan: number, rowSpan: number): void {
+ hovered = { colSpan, rowSpan };
+ onPreview({ colSpan, rowSpan });
+ }
+
+ function commit(): void {
+ onCommit(hovered.colSpan, hovered.rowSpan);
+ }
+
+ function keydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ return;
+ }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commit();
+ return;
+ }
+ let dc = 0;
+ let dr = 0;
+ if (e.key === "ArrowRight") dc = 1;
+ else if (e.key === "ArrowLeft") dc = -1;
+ else if (e.key === "ArrowDown") dr = 1;
+ else if (e.key === "ArrowUp") dr = -1;
+ else return;
+ e.preventDefault();
+ hover(
+ Math.max(1, Math.min(cols, hovered.colSpan + dc)),
+ Math.max(1, Math.min(rows, hovered.rowSpan + dr)),
+ );
+ }
+
+ let root = $state<HTMLDivElement | undefined>();
+ onMount(() => root?.focus());
+</script>
+
+<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex -->
+<div
+ bind:this={root}
+ class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none"
+ role="group"
+ aria-label="Resize tile"
+ tabindex="0"
+ onkeydown={keydown}
+ onmouseleave={() => onPreview(null)}
+>
+ <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p>
+ <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}>
+ {#each Array.from({ length: rows }) as _, row (row)}
+ {#each Array.from({ length: cols }) as __, col (col)}
+ <button
+ type="button"
+ class={`size-6 border border-base-content/20 ${
+ col < hovered.colSpan && row < hovered.rowSpan
+ ? "bg-primary/60"
+ : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1
+ ? "bg-primary/20"
+ : ""
+ }`}
+ aria-label={`Resize to ${col + 1} by ${row + 1}`}
+ onmouseenter={() => hover(col + 1, row + 1)}
+ onfocus={() => hover(col + 1, row + 1)}
+ onclick={() => {
+ hover(col + 1, row + 1);
+ commit();
+ }}
+ ></button>
+ {/each}
+ {/each}
+ </div>
+ <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p>
+ <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p>
+</div>
diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts
new file mode 100644
index 0000000..796a556
--- /dev/null
+++ b/src/features/workspaces/ui/ResizePicker.test.ts
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { geometryFor } from "../logic/tile-layout";
+import ResizePicker from "./ResizePicker.svelte";
+
+const WIDE = geometryFor(1920, 1080);
+const TALL = geometryFor(390, 844);
+
+function props() {
+ return {
+ onPreview: vi.fn(),
+ onCommit: vi.fn(),
+ onCancel: vi.fn(),
+ };
+}
+
+describe("ResizePicker", () => {
+ it("renders one small square per cap (4×2 wide, 2×2 tall)", () => {
+ const p = props();
+ const wide = render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+ expect(screen.getAllByRole("button")).toHaveLength(8);
+ wide.unmount();
+
+ render(ResizePicker, {
+ props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+ expect(screen.getAllByRole("button")).toHaveLength(4);
+ });
+
+ it("hovering square (r, c) previews that rectangle", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" }));
+ expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 });
+ });
+
+ it("clicking a square commits that size", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" }));
+ expect(p.onCommit).toHaveBeenCalledWith(2, 1);
+ });
+
+ it("arrow keys move within the picker, Enter commits, Escape cancels", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p },
+ });
+
+ await user.keyboard("{ArrowRight}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 });
+ await user.keyboard("{ArrowDown}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 });
+ await user.keyboard("{ArrowLeft}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 });
+
+ await user.keyboard("{Enter}");
+ expect(p.onCommit).toHaveBeenCalledWith(2, 2);
+ expect(p.onCancel).not.toHaveBeenCalled();
+
+ await user.keyboard("{Escape}");
+ expect(p.onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaving the picker clears the preview", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" }));
+ await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" }));
+ expect(p.onPreview).toHaveBeenLastCalledWith(null);
+ });
+});
diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte
new file mode 100644
index 0000000..0d8bef5
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+ import { fly } from "svelte/transition";
+ import type { WorkspaceEntry } from "@dispatch/wire";
+
+ let {
+ ws,
+ withPlugins = false,
+ onOpen,
+ onRename,
+ onResize,
+ onToggleStar,
+ onDetails,
+ onPlugins,
+ onDelete,
+ onClose,
+ }: {
+ ws: WorkspaceEntry;
+ /** The plugins port is present → the Plugins action renders. */
+ withPlugins?: boolean;
+ onOpen: () => void;
+ onRename: () => void;
+ onResize: () => void;
+ onToggleStar: () => void;
+ /** The details flyout (default cwd + default computer). */
+ onDetails: () => void;
+ onPlugins: () => void;
+ onDelete: () => void;
+ onClose: () => void;
+ } = $props();
+</script>
+
+<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+<div
+ class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4"
+ transition:fly={{ y: 80, duration: 150 }}
+ onclick={(e) => {
+ // The bar owns its clicks — don't let them deselect via the backdrop.
+ e.stopPropagation();
+ }}
+>
+ <div
+ class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl"
+ role="toolbar"
+ aria-label={`Actions for ${ws.title}`}
+ >
+ <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}>
+ {ws.starred ? "Unstar" : "Star"}
+ </button>
+ {#if withPlugins}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm text-error"
+ onclick={onDelete}>Delete</button
+ >
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close actions"
+ title="Close"
+ onclick={onClose}>✕</button
+ >
+ </div>
+</div>
diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts
new file mode 100644
index 0000000..4b0377e
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.test.ts
@@ -0,0 +1,88 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import TileActionBar from "./TileActionBar.svelte";
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 0,
+ ...overrides,
+ };
+}
+
+function handlers() {
+ return {
+ onOpen: vi.fn(),
+ onRename: vi.fn(),
+ onResize: vi.fn(),
+ onToggleStar: vi.fn(),
+ onDetails: vi.fn(),
+ onPlugins: vi.fn(),
+ onDelete: vi.fn(),
+ onClose: vi.fn(),
+ };
+}
+
+describe("TileActionBar", () => {
+ it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => {
+ const h = handlers();
+ render(TileActionBar, { props: { ws: ws(), ...h } });
+ expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument();
+ });
+
+ it("omits Plugins when no plugins port is present, renders it when one is", () => {
+ const h = handlers();
+ const without = render(TileActionBar, { props: { ws: ws(), ...h } });
+ expect(without.queryByRole("button", { name: "Plugins" })).toBeNull();
+ without.unmount();
+
+ render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } });
+ expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument();
+ });
+
+ it("labels the star action by the current state (Star ↔ Unstar)", () => {
+ const h = handlers();
+ const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } });
+ expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument();
+ unstarred.unmount();
+
+ render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } });
+ expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument();
+ });
+
+ it("dispatches each action", async () => {
+ const user = userEvent.setup();
+ const h = handlers();
+ render(TileActionBar, { props: { ws: ws(), ...h } });
+
+ await user.click(screen.getByRole("button", { name: "Open" }));
+ await user.click(screen.getByRole("button", { name: "Rename" }));
+ await user.click(screen.getByRole("button", { name: "Resize" }));
+ await user.click(screen.getByRole("button", { name: "Star" }));
+ await user.click(screen.getByRole("button", { name: "Details" }));
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ await user.click(screen.getByRole("button", { name: "Close actions" }));
+
+ expect(h.onOpen).toHaveBeenCalledTimes(1);
+ expect(h.onRename).toHaveBeenCalledTimes(1);
+ expect(h.onResize).toHaveBeenCalledTimes(1);
+ expect(h.onToggleStar).toHaveBeenCalledTimes(1);
+ expect(h.onDetails).toHaveBeenCalledTimes(1);
+ expect(h.onDelete).toHaveBeenCalledTimes(1);
+ expect(h.onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
deleted file mode 100644
index 8c004aa..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ /dev/null
@@ -1,319 +0,0 @@
-<script lang="ts">
- import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
- import { WorkspacePluginModal, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins";
- import type { WorkspaceStore } from "../store.svelte";
- import { relativeTime } from "../logic/view-model";
- import { workspacePath } from "../logic/route";
- import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
-
- let {
- ws,
- store,
- onNavigate,
- computers,
- hasActive,
- plugins,
- layout,
- }: {
- ws: WorkspaceEntry;
- store: WorkspaceStore;
- onNavigate: (path: string) => void;
- /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
- computers: readonly ComputerEntry[];
- /**
- * Optional port: returns whether the workspace has at least one active
- * (generating / queued) conversation — drives a loading-dots indicator on
- * the card. Wired by the composition root to the app store's
- * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests).
- */
- hasActive?: (workspaceId: string) => boolean;
- /**
- * Optional plugin store (plan 07): when present, the card renders a
- * "Plugins" button opening THIS workspace's enablement modal. Absent
- * (tests) → no button.
- */
- plugins?: PluginsStore;
- /**
- * Optional layout port (plan 04 §4.4): forwarded to the plugins modal
- * for the "Reset layout to default" affordance. Absent → the modal's
- * layout section doesn't render.
- */
- layout?: WorkspaceLayoutPort;
- } = $props();
-
- // Whether at least one conversation in this workspace is currently active
- // (generating). Reactive: the composition-root port reads the app store's
- // reactive tab set + lifecycle statuses, so this re-derives on change.
- const active = $derived(hasActive?.(ws.id) ?? false);
-
- // ── 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;
- }
-
- // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
- let savingComputer = $state(false);
- let computerError = $state<string | null>(null);
-
- async function saveComputer(computerId: string | null): Promise<void> {
- if (savingComputer) return;
- // No-op when unchanged (the select only fires on a real change, but guard).
- if (computerId === (ws.defaultComputerId ?? null)) return;
- savingComputer = true;
- computerError = null;
- const result = await store.setDefaultComputer(ws.id, computerId);
- savingComputer = false;
- if (!result.ok) computerError = result.error;
- }
-
- // ── Star (concurrency priority) ──────────────────────────────────────────────
- let savingStar = $state(false);
- let starError = $state<string | null>(null);
-
- async function toggleStar(): Promise<void> {
- if (savingStar) return;
- savingStar = true;
- starError = null;
- try {
- // Optimistic: the store flips `starred` immediately and re-sorts; revert
- // on error is handled there. We read `ws.starred` for the target value.
- const result = await store.setStarred(ws.id, !ws.starred);
- if (!result.ok) starError = result.error;
- } catch (err) {
- // A throw (e.g. a rejected effect) — surface it; the store already
- // reverted the optimistic flip if it got far enough to apply it.
- starError = err instanceof Error ? err.message : "Star toggle failed";
- } finally {
- savingStar = false;
- }
- }
-
- // ── Per-workspace plugin modal (plan 07 §7.3) ─────────────────────────────
- // Enable/disable what THIS workspace uses. Unloaded plugins are greyed out
- // + unselectable there — only the global manager (page header) loads code.
- let pluginsOpen = $state(false);
-
- // ── 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;
- }
-</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}
- {#if active}
- <span
- class="loading loading-dots loading-xs shrink-0 text-primary"
- aria-label="Workspace has active conversations"
- title="A conversation in this workspace is generating"></span
- >
- {/if}
- <span class="font-mono text-xs opacity-50">/{ws.id}</span>
- <button
- type="button"
- class="btn btn-ghost btn-xs px-1"
- disabled={savingStar}
- aria-pressed={ws.starred}
- aria-label={ws.starred ? "Unstar workspace" : "Star workspace"}
- title={ws.starred
- ? "Starred — its agents get concurrency priority. Click to unstar."
- : "Star this workspace to give its agents concurrency priority."}
- onclick={toggleStar}
- >
- {#if savingStar}
- <span class="loading loading-spinner loading-xs"></span>
- {:else if ws.starred}
- <span class="text-warning" aria-hidden="true">★</span>
- {:else}
- <span class="opacity-40" aria-hidden="true">☆</span>
- {/if}
- </button>
- <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}
-
- {#if starError}
- <p class="text-xs text-error">{starError}</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 items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
- <ComputerSelect
- value={ws.defaultComputerId}
- {computers}
- disabled={savingComputer}
- onSelect={saveComputer}
- />
- {#if savingComputer}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- <div class="flex justify-start">
- <a
- class="btn"
- href={workspacePath(ws.id)}
- onclick={(e) => {
- e.preventDefault();
- onNavigate(workspacePath(ws.id));
- }}
- >
- Open
- </a>
- {#if plugins}
- <button
- type="button"
- class="btn btn-outline btn-sm ml-2"
- onclick={() => (pluginsOpen = true)}
- title="Choose which plugins this workspace uses"
- >
- Plugins
- </button>
- {/if}
- </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}
-
- {#if computerError}
- <p class="text-xs text-error">{computerError}</p>
- {:else if !ws.defaultComputerId}
- <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
- {/if}
-</li>
-
-{#if pluginsOpen && plugins}
- <WorkspacePluginModal
- workspaceId={ws.id}
- workspaceTitle={ws.title}
- {plugins}
- {...(layout ? { layout } : {})}
- onClose={() => (pluginsOpen = false)}
- />
-{/if}
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
deleted file mode 100644
index 72f4e60..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import type { WorkspaceEntry } from "@dispatch/wire";
-import { render, screen } from "@testing-library/svelte";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import type { WorkspaceResult } from "../adapter/http";
-import type { WorkspaceStore } from "../store.svelte";
-import WorkspaceCard from "./WorkspaceCard.svelte";
-
-function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
- return {
- id: "my-ws",
- title: "My Workspace",
- defaultCwd: null,
- defaultComputerId: null,
- starred: false,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- ...overrides,
- };
-}
-
-/** A fake store that records calls + resolves ok. */
-function fakeStore() {
- return {
- rename: vi.fn(
- async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, title: _title }),
- }),
- ),
- setDefaultCwd: vi.fn(
- async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultCwd }),
- }),
- ),
- setDefaultComputer: vi.fn(
- async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultComputerId: computerId }),
- }),
- ),
- setStarred: vi.fn(
- async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, starred }),
- }),
- ),
- remove: vi.fn(
- async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
- ok: true,
- value: { closedCount: 0 },
- }),
- ),
- };
-}
-
-describe("WorkspaceCard", () => {
- it("renders the title, slug, and an Open link", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
- expect(screen.getByText("My Workspace")).toBeInTheDocument();
- expect(screen.getByText("/my-ws")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
- });
-
- it("double-clicking the title reveals an edit input", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
- });
-
- it("renames via the store on Enter", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- const input = screen.getByLabelText("Workspace title");
- await user.clear(input);
- await user.type(input, "Renamed{Enter}");
-
- expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
- });
-
- it("enables Set only when the cwd differs, then saves it", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- expect(input).toHaveValue("/old");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, "/new/path");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
-
- await user.click(screen.getByRole("button", { name: "Set" }));
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
- });
-
- it("clears the cwd to null when saved empty (inherits the server default)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- await user.clear(input);
- await user.click(screen.getByRole("button", { name: "Set" }));
-
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
- });
-
- it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- const onNavigate = vi.fn();
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
-
- const open = screen.getByRole("link", { name: "Open" });
- // Still a real link (progressive enhancement): href points at the workspace.
- expect(open).toHaveAttribute("href", "/my-ws");
- // But it no longer opens a new browser tab.
- expect(open).not.toHaveAttribute("target", "_blank");
- // Clicking navigates in-place via the SPA callback.
- await user.click(open);
- expect(onNavigate).toHaveBeenCalledTimes(1);
- expect(onNavigate).toHaveBeenCalledWith("/my-ws");
- });
-
- // ── Active indicator (loading dots) ──────────────────────────────────────
-
- it("shows no loading-dots when no hasActive port is given", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
- expect(container.querySelector(".loading-dots")).toBeNull();
- });
-
- it("shows no loading-dots when hasActive returns false", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: {
- ws: fakeEntry(),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: () => false,
- },
- });
- expect(container.querySelector(".loading-dots")).toBeNull();
- });
-
- it("shows loading-dots when hasActive returns true", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: {
- ws: fakeEntry(),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: () => true,
- },
- });
- const dots = container.querySelector(".loading-dots");
- expect(dots).not.toBeNull();
- // Accessible label ties the indicator to the workspace-active concept.
- expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations");
- });
-
- it("forwards the workspace id to hasActive", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const seen: string[] = [];
- render(WorkspaceCard, {
- props: {
- ws: fakeEntry({ id: "proj-x" }),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: (id: string) => {
- seen.push(id);
- return false;
- },
- },
- });
- expect(seen).toEqual(["proj-x"]);
- });
-
- it("renders an outline star button for an unstarred workspace", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
- const star = screen.getByRole("button", { name: "Star workspace" });
- expect(star).toHaveAttribute("aria-pressed", "false");
- });
-
- it("renders a filled star button for a starred workspace", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
- });
- const star = screen.getByRole("button", { name: "Unstar workspace" });
- expect(star).toHaveAttribute("aria-pressed", "true");
- });
-
- it("toggles the star via the store on click", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
- expect(store.setStarred).toHaveBeenCalledWith("my-ws", true);
- });
-
- it("clicking a starred workspace's star calls setStarred(id, false)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Unstar workspace" }));
- expect(store.setStarred).toHaveBeenCalledWith("my-ws", false);
- });
-
- it("renders no star error on a successful toggle", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
- expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument();
- });
-
- it("shows an inline error when setStarred fails (result.ok false)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- // The store reverts the optimistic flip on failure, so the entry's
- // `starred` stays false — fake the revert by returning ok:false unchanged.
- store.setStarred = vi.fn(
- async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
- );
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
-
- expect(screen.getByText("boom")).toBeInTheDocument();
- });
-
- it("re-enables the star button after a failure (savingStar resets)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- store.setStarred = vi.fn(
- async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
- );
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const star = screen.getByRole("button", { name: "Star workspace" });
- await user.click(star);
- // After the failed toggle, the button is NOT disabled (savingStar reset).
- expect(star).not.toBeDisabled();
- });
-
- it("re-enables the star button even when setStarred throws", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => {
- throw new Error("network");
- });
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const star = screen.getByRole("button", { name: "Star workspace" });
- await user.click(star);
- // savingStar must reset via try/finally even on a throw.
- expect(star).not.toBeDisabled();
- expect(screen.getByText("network")).toBeInTheDocument();
- });
-});
diff --git a/src/features/workspaces/ui/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte
new file mode 100644
index 0000000..6595725
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceDetails.svelte
@@ -0,0 +1,132 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
+
+ let {
+ ws,
+ store,
+ computers,
+ onClose,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ onClose: () => void;
+ } = $props();
+
+ // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ──
+ 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;
+ }
+
+ // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
+ let savingComputer = $state(false);
+ let computerError = $state<string | null>(null);
+
+ async function saveComputer(computerId: string | null): Promise<void> {
+ if (savingComputer) return;
+ // No-op when unchanged (the select only fires on a real change, but guard).
+ if (computerId === (ws.defaultComputerId ?? null)) return;
+ savingComputer = true;
+ computerError = null;
+ const result = await store.setDefaultComputer(ws.id, computerId);
+ savingComputer = false;
+ if (!result.ok) computerError = result.error;
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}>
+ <div class="modal-box">
+ <div class="flex items-start justify-between gap-2">
+ <div>
+ <h2 class="text-lg font-bold">{ws.title}</h2>
+ <p class="font-mono text-xs opacity-50">/{ws.id}</p>
+ </div>
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close details"
+ onclick={onClose}>✕</button
+ >
+ </div>
+
+ <div class="mt-4 flex flex-col gap-3">
+ <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") void saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={() => void saveCwd()}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </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}
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
+ <ComputerSelect
+ value={ws.defaultComputerId}
+ {computers}
+ disabled={savingComputer}
+ onSelect={(id) => void saveComputer(id)}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
+ </div>
+ </div>
+ <!-- Click outside closes. -->
+ <button
+ type="button"
+ class="modal-backdrop"
+ aria-label="Close details backdrop"
+ onclick={onClose}></button>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts
new file mode 100644
index 0000000..c903ac0
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts
@@ -0,0 +1,114 @@
+import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceResult } from "../adapter/http";
+import type { WorkspaceStore } from "../store.svelte";
+import WorkspaceDetails from "./WorkspaceDetails.svelte";
+
+function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 0,
+ ...overrides,
+ };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ setDefaultComputer: vi.fn(
+ async (
+ id: string,
+ defaultComputerId: string | null,
+ ): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId }),
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+}
+
+describe("WorkspaceDetails", () => {
+ it("renders the title, slug, cwd input, and computer select", () => {
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByLabelText("Default working directory")).toBeInTheDocument();
+ expect(screen.getByLabelText("Computer")).toBeInTheDocument();
+ });
+
+ it("saves the cwd via the store; an empty value clears to null", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const onClose = vi.fn();
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ await user.clear(input);
+ await user.click(screen.getByRole("button", { name: "Set" }));
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
+ });
+
+ it("a failed cwd save surfaces the error", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ store.setDefaultCwd = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }),
+ );
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} },
+ });
+
+ await user.type(screen.getByLabelText("Default working directory"), "!{Enter}");
+ expect(await screen.findByText("nope")).toBeInTheDocument();
+ });
+
+ it("picking a computer saves it via the store", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const computers: ComputerEntry[] = [
+ {
+ alias: "lab",
+ hostName: "lab.example",
+ port: 22,
+ user: "me",
+ identityFile: null,
+ knownHost: true,
+ usageCount: 0,
+ },
+ ];
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store, computers, onClose: () => {} },
+ });
+
+ await user.selectOptions(screen.getByLabelText("Computer"), "lab");
+ expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab");
+ });
+
+ it("the close button dismisses the dialog", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Close details" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte
new file mode 100644
index 0000000..4059690
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.svelte
@@ -0,0 +1,109 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import type { Placement } from "../logic/tile-layout";
+
+ let {
+ ws,
+ store,
+ placement,
+ selected = false,
+ renaming = false,
+ active = false,
+ onRenameEnd,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ /** The tile's resolved footprint (already preview-adjusted by the grid). */
+ placement: Placement;
+ selected?: boolean;
+ /** The parent-driven rename mode (the action bar's Rename action). */
+ renaming?: boolean;
+ /** At least one conversation in this workspace is generating/queued. */
+ active?: boolean;
+ /** The tile finished its rename session (saved or cancelled). */
+ onRenameEnd: () => void;
+ } = $props();
+
+ // ── Title: double-click to rename inline (semantics preserved from the
+ // WorkspaceCard), or enter via the action bar's Rename action (`renaming`).
+ let editing = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEdit(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editing = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ // Seed (only) when the parent-driven rename mode turns on.
+ $effect(() => {
+ if (renaming) startEdit();
+ });
+
+ async function saveTitle(): Promise<void> {
+ if (!editing) return;
+ const title = titleDraft.trim();
+ editing = false;
+ onRenameEnd();
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editing = false;
+ titleError = null;
+ onRenameEnd();
+ }
+
+ // The face's semantic key (tests read it).
+ const faceKey = $derived(`tile-${placement.id}`);
+</script>
+
+<div data-tile-face={faceKey} class="flex h-full w-full flex-col gap-1 p-3">
+ {#if editing}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") void saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={() => void saveTitle()}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="line-clamp-2 cursor-default text-lg leading-tight font-bold"
+ title="Double-click to rename"
+ ondblclick={startEdit}>{ws.title}</span
+ >
+ {/if}
+
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="mt-auto flex items-center gap-2 text-xs opacity-80">
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0 text-primary"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span>
+ {/if}
+ <span>
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ </span>
+ <span class="ml-auto font-mono opacity-70">{relativeTime(ws.lastActivityAt, Date.now())}</span>
+ </div>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts
new file mode 100644
index 0000000..918aa1c
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.test.ts
@@ -0,0 +1,157 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceResult } from "../adapter/http";
+import type { Placement } from "../logic/tile-layout";
+import type { WorkspaceStore } from "../store.svelte";
+import WorkspaceTile from "./WorkspaceTile.svelte";
+
+function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: Date.now() - 90_000, // "1m" relative time
+ conversationCount: 3,
+ ...overrides,
+ };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ rename: vi.fn(
+ async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title }),
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+}
+
+function fakePlacement(overrides: Partial<Placement> = {}): Placement {
+ return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides };
+}
+
+describe("WorkspaceTile", () => {
+ it("renders the title, slug, conversation count, and relative last-activity", () => {
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ onRenameEnd: () => {},
+ },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByText(/3 conversations/)).toBeInTheDocument();
+ expect(screen.getByText("1m")).toBeInTheDocument();
+ });
+
+ it("shows the active indicator only when the active port says so", async () => {
+ const { container, rerender } = render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ active: true,
+ onRenameEnd: () => {},
+ },
+ });
+ expect(
+ container.querySelector('[aria-label="Workspace has active conversations"]'),
+ ).not.toBeNull();
+
+ await rerender({
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ active: false,
+ onRenameEnd: () => {},
+ });
+ expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull();
+ });
+
+ it("double-clicking the title reveals the rename input (semantics preserved)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ onRenameEnd: () => {},
+ },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
+ });
+
+ it("the parent-driven rename mode (action bar) opens the same inline editor", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd: () => {},
+ },
+ });
+ const input = screen.getByLabelText("Workspace title");
+ expect(input).toHaveValue("My Workspace");
+
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+ expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
+ });
+
+ it("a failed rename surfaces the error on the face", async () => {
+ const user = userEvent.setup();
+ const store = {
+ rename: vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: false,
+ error: "boom",
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd: () => {},
+ },
+ });
+
+ await user.type(screen.getByLabelText("Workspace title"), "x{Enter}");
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("an unchanged title does not hit the store and still ends the session", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const onRenameEnd = vi.fn();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd,
+ },
+ });
+
+ await user.type(screen.getByLabelText("Workspace title"), "{Enter}");
+ expect(store.rename).not.toHaveBeenCalled();
+ expect(onRenameEnd).toHaveBeenCalled();
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 5a4d88f..276a811 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,10 +1,32 @@
<script lang="ts">
- import { onMount } from "svelte";
+ import { onMount, untrack } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
- import { PluginManager, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins";
+ import {
+ PluginManager,
+ WorkspacePluginModal,
+ type PluginsStore,
+ type WorkspaceLayoutPort,
+ } from "../../plugins";
+ import { createLocalStore } from "../../../adapters/local-storage";
import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { workspacePath } from "../logic/route";
+ import {
+ DEFAULT_VIEWPORT,
+ geometryFor,
+ pack,
+ type GridGeometry,
+ type Span,
+ } from "../logic/tile-layout";
+ import {
+ createTileLayoutStore,
+ TILE_LAYOUT_STORAGE_KEY,
+ type TileLayoutStore,
+ } from "../tile-layout-store.svelte";
+ import HomeHeader from "./HomeHeader.svelte";
+ import HomeTileGrid from "./HomeTileGrid.svelte";
+ import TileActionBar from "./TileActionBar.svelte";
+ import ResizePicker from "./ResizePicker.svelte";
+ import WorkspaceDetails from "./WorkspaceDetails.svelte";
let {
store,
@@ -13,136 +35,291 @@
hasActive,
plugins,
layout,
+ tiles: tilesOverride,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
computers: readonly ComputerEntry[];
/**
- * Optional port forwarded to each {@link WorkspaceCard}: whether the
- * workspace has at least one active (generating / queued) conversation.
+ * Optional port: whether the workspace has at least one active
+ * (generating / queued) conversation — the tile's live indicator.
* Wired by the composition root to the app store. Absent → no indicator.
*/
hasActive?: (workspaceId: string) => boolean;
/**
- * The plugin manager store (plan 07): drives the page-header global
- * "Plugins" button + the per-card workspace modals (forwarded to
- * {@link WorkspaceCard}). Optional (absent in tests that don't exercise
- * plugins) → the buttons don't render.
+ * The plugin manager store (plan 07): drives the header's global
+ * "Plugins" button + the action bar's per-workspace Plugins action.
+ * Optional (absent in tests that don't exercise plugins).
*/
plugins?: PluginsStore;
/**
- * The layout port (plan 04 §4.4): forwarded to each {@link WorkspaceCard}
- * for the plugins modal's "Reset layout to default" affordance. Absent →
- * the modal's layout section doesn't render.
+ * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins
+ * modal's "Reset layout to default" affordance. Absent → the modal's
+ * layout section doesn't render.
*/
layout?: WorkspaceLayoutPort;
+ /**
+ * The persisted tile layout store. Absent → created here over the real
+ * localStorage adapter (the page is the effect edge); tests inject one.
+ */
+ tiles?: TileLayoutStore;
} = $props();
onMount(() => {
void store.refresh();
});
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ // ── The persisted tile layout (order + spans only — never positions). ─────
+ // The store is created once per page from the (inert) override prop — the
+ // repo's `untrack` pattern for initial-value captures (cf. App.svelte).
+ const tiles: TileLayoutStore =
+ untrack(() => tilesOverride) ??
+ createTileLayoutStore(
+ createLocalStore(TILE_LAYOUT_STORAGE_KEY, {
+ storage: typeof localStorage !== "undefined" ? localStorage : undefined,
+ }),
+ );
+
+ // Keep the layout merged with the live workspace list: unknown workspaces
+ // (deep-link auto-creates) append at the end; deleted ones drop. The no-op
+ // guard inside sync keeps this effect from looping. Skipped until the first
+ // non-empty list arrives — `sync([])` before the initial `refresh()` resolves
+ // would WIPE the persisted order (the backend always serves "default", so an
+ // empty list only ever means "not loaded yet").
+ $effect(() => {
+ const ids = store.list.map((ws) => ws.id);
+ if (ids.length === 0) return;
+ tiles.sync(ids);
+ });
+
+ // ── The tile canvas' size decides the geometry (ONE pure function — no
+ // breakpoint classes). Unmeasured (tests) → the default landscape viewport.
+ let regionEl = $state<HTMLDivElement | undefined>();
+ let size = $state<{ width: number; height: number } | undefined>();
+ const geometry: GridGeometry = $derived(
+ geometryFor(size?.width ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height),
+ );
+
+ $effect(() => {
+ const el = regionEl;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ const rect = entries[0]?.contentRect;
+ if (rect) size = { width: rect.width, height: rect.height };
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ });
+
+ // The grid view: the SAME persisted data packs into either geometry —
+ // positions re-derive here, spans clamp for the RENDER only.
+ const placements = $derived(pack(tiles.entries, geometry));
+ const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws])));
+
+ // ── Selection + action bar ─────────────────────────────────────────────────
+ let selectedId = $state<string | null>(null);
+ const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null);
+
+ function select(id: string): void {
+ selectedId = id;
+ }
+
+ function deselect(): void {
+ selectedId = null;
+ }
+
+ // ── Rename mode (the create flow + the action bar's Rename action) ────────
+ let renamingId = $state<string | null>(null);
+
+ // ── Resize picker (transient ghost preview; commit persists) ──────────────
+ let resizing = $state<{ id: string; span: Span | null } | null>(null);
+ const resizingEntry = $derived(
+ resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null,
+ );
+
+ function startResize(): void {
+ if (!selected) return;
+ const entry = tiles.entries.find((tile) => tile.id === selected.id);
+ resizing = {
+ id: selected.id,
+ span: entry
+ ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan }
+ : { colSpan: 1, rowSpan: 1 },
+ };
+ }
+
+ function commitResize(colSpan: number, rowSpan: number): void {
+ if (!resizing) return;
+ tiles.resize(resizing.id, colSpan, rowSpan);
+ resizing = null;
+ }
+
+ function cancelResize(): void {
+ resizing = null;
+ }
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ // ── Details flyout (the dense per-card settings). Id-based + gated on the
+ // selection, so deselecting (Escape) also dismisses it. ─────────────────────
+ let detailsForId = $state<string | null>(null);
+ const detailsFor = $derived(
+ selectedId !== null && detailsForId === selectedId
+ ? (workspacesById.get(selectedId) ?? null)
+ : null,
+ );
- // Global plugin manager modal (plan 07 §7.2): opened from the header
- // button; the ONLY surface that loads/unloads code (strict layering).
+ // ── Per-workspace plugin modal ─────────────────────────────────────────────
+ let pluginsModalId = $state<string | null>(null);
+ const pluginsModalFor = $derived(
+ pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null,
+ );
+
+ // Global plugin manager modal (plan 07 §7.2): the ONLY surface that
+ // loads/unloads code (strict layering).
let managerOpen = $state(false);
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ // ── Actions (the action bar) ───────────────────────────────────────────────
+ function openWorkspace(id: string): void {
+ deselect();
+ onNavigate(workspacePath(id));
+ }
+
+ async function deleteSelected(): Promise<void> {
+ if (!selected) return;
+ const ws = selected;
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
return;
}
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
+ const result = await store.remove(ws.id);
+ if (result.ok) deselect();
+ }
+
+ async function toggleStar(): Promise<void> {
+ if (!selected) return;
+ await store.setStarred(selected.id, !selected.starred);
+ }
+
+ // ── Create (header popover): create on the backend — do NOT navigate —
+ // place the new tile in the first empty slot, and go straight into rename.
+ async function createWorkspace(
+ slug: string,
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
+ const result = await store.ensure(slug);
+ if (!result.ok) return { ok: false, error: result.error };
+ await store.refresh();
+ // Idempotent: if the list refresh's sync already appended the id, this
+ // MOVES it into the first empty slot instead of duplicating.
+ tiles.placeFirstEmpty(slug, geometry);
+ renamingId = slug;
+ return { ok: true };
+ }
+
+ // ── Sort overflow: re-apply the recent-activity order ──────────────────────
+ function sortByRecent(): void {
+ const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt]));
+ tiles.sortByRecentActivity(activity);
+ }
+
+ // Escape unwinds the overlays: resize → selection.
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape") return;
+ if (resizing) {
+ cancelResize();
+ } else if (selectedId !== null) {
+ deselect();
+ }
}
</script>
-<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
- <header class="flex items-center justify-between">
- <h1 class="text-2xl font-bold">Workspaces</h1>
- <div class="flex items-center gap-2">
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- {#if plugins}
- <button
- type="button"
- class="btn btn-outline btn-sm"
- onclick={() => (managerOpen = true)}
- title="Open the plugin manager (load/unload plugins globally)"
- >
- Plugins
- </button>
- {/if}
- </div>
- </header>
-
- <form
- class="flex items-end gap-2"
- onsubmit={(e) => {
- e.preventDefault();
- createWorkspace();
- }}
- >
- <div class="flex-1">
- <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60"
- >New workspace</label
- >
- <input
- id="new-ws"
- class="input input-bordered w-full"
- placeholder="my-workspace"
- bind:value={newSlug}
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
- </form>
- {#if slugError}
- <p class="text-xs text-error">{slugError}</p>
- {/if}
-
- <div class="flex-1 overflow-y-auto">
+<svelte:window onkeydown={handleKeydown} />
+
+<div class="flex h-screen w-full flex-col">
+ <HomeHeader
+ compact={geometry.mode === "tall"}
+ withPlugins={plugins !== undefined}
+ withSort={store.list.length > 1}
+ onCreate={createWorkspace}
+ onOpenPlugins={() => (managerOpen = true)}
+ onSort={() => void sortByRecent()}
+ />
+
+ <div bind:this={regionEl} class="min-h-0 w-full flex-1">
{#if store.loading && store.list.length === 0}
<div class="flex h-32 items-center justify-center">
<span class="loading loading-spinner loading-sm opacity-60"></span>
</div>
{:else if store.list.length === 0}
<p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ No workspaces yet. Create one with <strong>New workspace</strong> above, or visit
+ <code>/your-name</code> in the URL.
</p>
{:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard
- {ws}
- {store}
- {onNavigate}
- {computers}
- {...(hasActive ? { hasActive } : {})}
- {...(plugins ? { plugins } : {})}
- {...(layout ? { layout } : {})}
- />
- {/each}
- </ul>
+ <HomeTileGrid
+ {geometry}
+ {placements}
+ {workspacesById}
+ {store}
+ {selectedId}
+ {renamingId}
+ preview={
+ resizing?.id !== undefined && resizing.span !== null
+ ? { id: resizing.id, ...resizing.span }
+ : null
+ }
+ {...(hasActive ? { hasActive } : {})}
+ onOpen={openWorkspace}
+ onSelect={select}
+ onReorder={(id, toIndex) => tiles.move(id, toIndex)}
+ onRenameEnd={() => (renamingId = null)}
+ />
{/if}
</div>
</div>
+{#if selected && !renamingId}
+ <TileActionBar
+ ws={selected}
+ withPlugins={plugins !== undefined}
+ onOpen={() => openWorkspace(selected.id)}
+ onRename={() => (renamingId = selected.id)}
+ onResize={startResize}
+ onToggleStar={() => void toggleStar()}
+ onDetails={() => (detailsForId = selected.id)}
+ onPlugins={() => (pluginsModalId = selected.id)}
+ onDelete={() => void deleteSelected()}
+ onClose={deselect}
+ />
+{/if}
+
+{#if resizing && resizingEntry}
+ <ResizePicker
+ {geometry}
+ initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }}
+ onPreview={(span) => {
+ if (resizing) resizing = { id: resizing.id, span };
+ }}
+ onCommit={commitResize}
+ onCancel={cancelResize}
+ />
+{/if}
+
+{#if detailsFor}
+ <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} />
+{/if}
+
+{#if pluginsModalFor && plugins}
+ <WorkspacePluginModal
+ workspaceId={pluginsModalFor.id}
+ workspaceTitle={pluginsModalFor.title}
+ {plugins}
+ {...(layout ? { layout } : {})}
+ onClose={() => (pluginsModalId = null)}
+ />
+{/if}
+
{#if managerOpen && plugins}
<PluginManager {plugins} onClose={() => (managerOpen = false)} />
{/if}
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
new file mode 100644
index 0000000..eb5b293
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -0,0 +1,182 @@
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
+import { render, screen, waitFor } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { LocalStore } from "../../../adapters/local-storage";
+import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http";
+import type { TileLayoutData, TileLayoutEntry } from "../logic/tile-layout";
+import { createTileLayoutStore } from "../tile-layout-store.svelte";
+import WorkspacesHome from "./WorkspacesHome.svelte";
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 1,
+ ...overrides,
+ };
+}
+
+/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */
+function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
+ return {
+ list: vi.fn(async (): Promise<WorkspaceEntry[]> => list),
+ // Create-on-miss: the backend persists the new workspace, so the next
+ // `list` serves it too.
+ ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => {
+ if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id }));
+ return { ok: true, value: ws({ id, title: id }) };
+ }),
+ get: vi.fn(),
+ setTitle: vi.fn(),
+ setDefaultCwd: vi.fn(),
+ setDefaultComputer: vi.fn(),
+ star: vi.fn(),
+ unstar: vi.fn(),
+ delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => {
+ // Mirrors the backend: the workspace leaves the list.
+ list.splice(0);
+ return { ok: true, value: { closedCount: 0 } };
+ }),
+ } as unknown as WorkspaceHttp;
+}
+
+function memoryLayout(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & {
+ data: TileLayoutData | null;
+} {
+ const store = {
+ data: initial,
+ load: () => store.data,
+ save: (value: TileLayoutData) => {
+ store.data = value;
+ },
+ clear: () => {
+ store.data = null;
+ },
+ };
+ return store;
+}
+
+describe("WorkspacesHome (tile canvas)", () => {
+ it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => {
+ const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })];
+ const layout = memoryLayout({
+ version: 1,
+ tiles: [
+ { id: "b", order: 0, colSpan: 1, rowSpan: 1 },
+ { id: "a", order: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ });
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore(layout),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace B" });
+ const wrappers = document.querySelectorAll("[data-tile-id]");
+ expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]);
+ });
+
+ it("clicking a tile navigates to the workspace path", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn();
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
+ onNavigate,
+ computers: [],
+ tiles: createTileLayoutStore(memoryLayout()),
+ },
+ });
+
+ const tile = await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(tile);
+ expect(onNavigate).toHaveBeenCalledWith("/a");
+ });
+
+ it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const onNavigate = vi.fn();
+ const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list));
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate,
+ computers: [],
+ tiles: createTileLayoutStore(memoryLayout()),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "fresh-ws{Enter}");
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument();
+ });
+ expect(onNavigate).not.toHaveBeenCalled();
+ // The backend was asked to create it (create-on-miss).
+ expect(store.list.map((w) => w.id)).toContain("fresh-ws");
+ });
+
+ it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => {
+ const user = userEvent.setup();
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ const list = [ws({ id: "a", title: "A" })];
+ const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list));
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore(memoryLayout()),
+ },
+ });
+
+ const tile = await screen.findByRole("button", { name: "Open workspace A" });
+ await user.pointer({ keys: "[MouseRight]", target: tile });
+
+ const deleteButton = await screen.findByRole("button", { name: "Delete" });
+ await user.click(deleteButton);
+
+ expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?'));
+ await waitFor(() => {
+ expect(store.list.length).toBe(0);
+ });
+ confirmSpy.mockRestore();
+ });
+
+ it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => {
+ const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })];
+ const layout = memoryLayout({
+ version: 1,
+ tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }],
+ });
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore(layout),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace B" });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ const wrappers = document.querySelectorAll("[data-tile-id]");
+ expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]);
+ // And the sync persisted the appended entry.
+ const entries = (layout.data?.tiles ?? []).map((tile: TileLayoutEntry) => tile.id);
+ expect(entries).toEqual(["b", "a"]);
+ });
+});