summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/ArchivedWorkspaces.svelte119
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte198
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts94
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte568
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts200
-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.svelte73
-rw-r--r--src/features/workspaces/ui/TileActionBar.test.ts93
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte279
-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.ts115
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.svelte146
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.test.ts171
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte399
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts333
17 files changed, 2763 insertions, 657 deletions
diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte
new file mode 100644
index 0000000..584fd68
--- /dev/null
+++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte
@@ -0,0 +1,119 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+
+ let {
+ store,
+ onOpen,
+ onClose,
+ }: {
+ store: WorkspaceStore;
+ /** Open (view) a workspace — navigates to `/<id>`. */
+ onOpen: (id: string) => void;
+ onClose: () => void;
+ } = $props();
+
+ // Load the archived list when the modal mounts.
+ $effect(() => {
+ void store.refreshArchived();
+ });
+
+ let busyId = $state<string | null>(null);
+ let actionError = $state<string | null>(null);
+
+ async function unarchive(ws: WorkspaceEntry): Promise<void> {
+ if (busyId !== null) return;
+ busyId = ws.id;
+ actionError = null;
+ const result = await store.setArchived(ws.id, false);
+ busyId = null;
+ if (!result.ok) {
+ actionError = result.error;
+ return;
+ }
+ // Refresh the archived list so the unarchived workspace leaves it.
+ void store.refreshArchived();
+ }
+
+ function open(ws: WorkspaceEntry): void {
+ onOpen(ws.id);
+ onClose();
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Archived workspaces">
+ <div class="modal-box max-w-lg">
+ <div class="flex items-start justify-between gap-2">
+ <div>
+ <h2 class="text-lg font-bold">Archived workspaces</h2>
+ <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p>
+ </div>
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close archived workspaces"
+ onclick={onClose}>✕</button
+ >
+ </div>
+
+ {#if actionError}
+ <p class="mt-3 text-xs text-error">{actionError}</p>
+ {/if}
+
+ <div class="mt-4">
+ {#if store.archivedLoading && store.archivedList.length === 0}
+ <div class="flex justify-center py-8">
+ <span class="loading loading-spinner loading-md opacity-60"></span>
+ </div>
+ {:else if store.archivedList.length === 0}
+ <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.archivedList as ws (ws.id)}
+ <li
+ class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2"
+ >
+ <div class="min-w-0 flex-1">
+ <p class="truncate font-semibold">{ws.title}</p>
+ <p class="font-mono text-xs opacity-50">
+ /{ws.id} · {ws.conversationCount} conv · {relativeTime(
+ ws.lastActivityAt,
+ Date.now(),
+ )} ago
+ </p>
+ </div>
+ <div class="flex shrink-0 items-center gap-1">
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => open(ws)}
+ aria-label={`Open ${ws.title}`}>View</button
+ >
+ <button
+ type="button"
+ class="btn btn-outline btn-xs"
+ disabled={busyId === ws.id}
+ onclick={() => void unarchive(ws)}
+ aria-label={`Unarchive ${ws.title}`}
+ >
+ {#if busyId === ws.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Unarchive
+ {/if}
+ </button>
+ </div>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </div>
+ <!-- Click outside closes. -->
+ <button
+ type="button"
+ class="modal-backdrop"
+ aria-label="Close archived workspaces backdrop"
+ onclick={onClose}></button>
+</div>
diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte
new file mode 100644
index 0000000..5329b2d
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.svelte
@@ -0,0 +1,198 @@
+<script lang="ts">
+ import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon";
+ import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon";
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+ 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,
+ onOpenArchived,
+ }: {
+ /** 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;
+ /** Open the archived-workspaces view. */
+ onOpenArchived: () => 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>
+
+<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace
+ top bar — gradient, drop shadow, engraved primary-blue title. -->
+<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12">
+ <div class="flex items-baseline gap-3">
+ <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1>
+ <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline">
+ Workspaces
+ </span>
+ </div>
+
+ <!-- Navbar actions (app.css): "New workspace" is the one raised button
+ (`.navbar-btn`, primary-tinted); the secondary actions are plain text
+ links (`.navbar-link`) — the light-blue colour + spacing say clickable
+ without button chrome. Compact: labels collapse to Phosphor icons. -->
+ <div class="flex items-center gap-1" class:gap-3={!compact}>
+ <!-- New workspace -->
+ <button
+ type="button"
+ class="navbar-btn navbar-btn-primary"
+ class:navbar-btn-icon={compact}
+ aria-label="New workspace"
+ title="New workspace"
+ onclick={() => (popoverOpen ? closePopover() : openPopover())}
+ >
+ <PlusIcon size={16} weight="bold" aria-hidden="true" />
+ {#if !compact}<span>New workspace</span>{/if}
+ </button>
+
+ {#if withPlugins}
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="Plugins"
+ title="Open the plugin manager (load/unload plugins globally)"
+ onclick={onOpenPlugins}
+ >
+ {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if}
+ </button>
+ {/if}
+
+ <!-- Archived workspaces -->
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="Archived workspaces"
+ title="View archived workspaces"
+ onclick={onOpenArchived}
+ >
+ {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if}
+ </button>
+
+ {#if withSort}
+ <!-- Overflow last, like a navbar's trailing menu. -->
+ <div class="dropdown dropdown-end">
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="More actions"
+ title="More"
+ >
+ <DotsThreeIcon size={20} weight="bold" aria-hidden="true" />
+ </button>
+ <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl">
+ <li>
+ <button type="button" onclick={onSort}>Reset arrangement 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..4db8b04
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.test.ts
@@ -0,0 +1,94 @@
+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(),
+ onOpenArchived: 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: "Reset arrangement 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 } });
+ const button = screen.getByRole("button", { name: "New workspace" });
+ expect(button).toHaveClass("navbar-btn-icon");
+ expect(button).toHaveTextContent(""); // icon only — the label collapses
+ expect(button.querySelector("svg")).not.toBeNull();
+ });
+});
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte
new file mode 100644
index 0000000..40a46c5
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.svelte
@@ -0,0 +1,568 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import { gsap } from "gsap";
+ import { Flip } from "gsap/Flip";
+ import { tick } from "svelte";
+ import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
+ import {
+ displacePlace,
+ dragAppendLimit,
+ type Cell,
+ type GridGeometry,
+ type Placement,
+ type Span,
+ } from "../logic/tile-layout";
+ import { tileTone, type TileTone } from "../logic/view-model";
+ import type { WorkspaceStore } from "../store.svelte";
+ import WorkspaceTile from "./WorkspaceTile.svelte";
+
+ gsap.registerPlugin(Flip);
+
+ let {
+ geometry,
+ placements,
+ workspacesById,
+ store,
+ selectedId = null,
+ renamingId = null,
+ preview = null,
+ hasActive,
+ onOpen,
+ onSelect,
+ onPlace,
+ onRenameEnd,
+ }: {
+ geometry: GridGeometry;
+ /** Packed placements of the COMMITTED layout (the drag preview derives). */
+ 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 `cell` (null = the pointer never resolved to a cell). */
+ onPlace: (id: string, cell: Cell | null) => 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;
+ /** Pointer position where the current press began (the follow transform base). */
+ let pressOrigin = $state<{ x: number; y: number } | null>(null);
+ /** Where within the pressed tile the pointer grabbed (its follow-transform base). */
+ let grabOffset = $state<{ x: number; y: number } | null>(null);
+ /** The tile mid-settle after a drop (excluded from FLIP until it lands). */
+ let settling = $state<string | null>(null);
+
+ 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 wasDragging = phase.kind === "dragging" ? phase.id : null;
+ 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") drop(emit.id, emit.targetCell);
+ }
+ if (phase.kind === "idle") {
+ // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above;
+ // a CANCEL mid-drag flies the tile back to its committed slot.
+ if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging);
+ pressOrigin = null;
+ grabOffset = null;
+ }
+ // Keep the long-press armed exactly while a TOUCH press is undecided.
+ if (phase.kind === "pressing" && phase.touch) armLongPress();
+ else disarmLongPress();
+ }
+
+ /** The tile being dragged (or settling after a drop) follows no FLIP. */
+ const excludedFromFlip = $derived(
+ new Set(
+ [phase.kind === "dragging" ? phase.id : null, settling].filter(
+ (id): id is string => id !== null,
+ ),
+ ),
+ );
+
+ /** Commit the drop, then settle the tile from the pointer onto its new cell. */
+ function drop(id: string, cell: Cell | null): void {
+ if (cell !== null) onPlace(id, cell);
+ flyBack(id);
+ }
+
+ /**
+ * Animate the tile's follow transform to identity at its (possibly new)
+ * committed slot — the drop settle AND the cancel/Escape fly-back. The tile
+ * is excluded from Flip until the animation completes so the two never
+ * fight over the same transform.
+ */
+ function flyBack(id: string): void {
+ const el = tileEls.get(id);
+ if (!el) return;
+ settling = id;
+ void tick().then(() => {
+ gsap.to(el, {
+ x: 0,
+ y: 0,
+ scale: 1,
+ duration: 0.2,
+ ease: "power2.out",
+ onComplete: () => {
+ gsap.set(el, { clearProps: "transform" });
+ settling = null;
+ },
+ });
+ });
+ }
+
+ /** 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;
+ }
+
+ /**
+ * Drag gestures live on the GRID CONTAINER, not the tiles: the container is
+ * never re-parented, never toggles `pointer-events`, and never unmounts, so
+ * pointer capture set here survives the whole gesture. (A captured element
+ * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an
+ * uncaptured pointer can't stream events over empty cells — the empty-cell
+ * bug.) Capture also keeps move/up flowing when the pointer leaves the grid.
+ */
+ function gridPointerDown(e: PointerEvent): void {
+ if (e.button !== 0) return;
+ const tileEl = (e.target as Element | null)?.closest("[data-tile-id]");
+ if (!tileEl || insideInteractive(e.target)) return;
+ const id = tileEl.getAttribute("data-tile-id");
+ if (id === null) return;
+ // Capture mouse/pen so fast drags keep streaming to this container. TOUCH
+ // is purposely NOT captured here: an early touch capture can suppress
+ // native scrolling (WebKit), and touch already gets the browser's implicit
+ // capture — after the long-press lift the moved events bubble up to this
+ // container anyway.
+ if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId);
+ // Where within the tile the pointer grabbed — the follow transform keeps
+ // THIS point under the pointer (not the tile's top-left origin).
+ const rect = tileEl.getBoundingClientRect();
+ grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ pressOrigin = { x: e.clientX, y: e.clientY };
+ dispatch({
+ type: "pointer-down",
+ id,
+ x: e.clientX,
+ y: e.clientY,
+ touch: e.pointerType === "touch",
+ });
+ }
+
+ function gridPointerMove(e: PointerEvent): void {
+ if (phase.kind === "idle") return;
+ dispatch({
+ type: "pointer-move",
+ x: e.clientX,
+ y: e.clientY,
+ targetCell: cellAt(e.clientX, e.clientY),
+ });
+ }
+
+ function gridPointerUp(): void {
+ if (phase.kind === "idle") return;
+ dispatch({ type: "pointer-up" });
+ }
+
+ // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the
+ // selection — the committed layout is untouched and the tile flies home.
+ function windowKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape" || phase.kind === "idle") return;
+ e.stopPropagation();
+ dispatch({ type: "cancel" });
+ }
+
+ // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ─────
+ let gridEl = $state<HTMLDivElement | undefined>();
+
+ const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
+ const pitchY = $derived(geometry.unit.height + geometry.unit.gap);
+
+ /**
+ * The drag target's clamp on the growth axis — at most 3 columns (wide) /
+ * rows (tall) past the current content edge. Measured on the COMMITTED
+ * placements (the drag preview never ratchets the limit mid-drag), so the
+ * canvas can't endlessly grow when dragging far right / down.
+ */
+ const appendLimit = $derived(dragAppendLimit(placements, geometry));
+
+ function cellAt(clientX: number, clientY: number): Cell | null {
+ const el = gridEl;
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns.
+ const cellW = wide ? pitchX : rect.width / geometry.flowCols;
+ const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1);
+ const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1);
+ return {
+ col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols),
+ row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit),
+ };
+ }
+
+ // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─
+ /** The dragged tile's id while a drag is in flight (else null). */
+ const draggedId = $derived<string | null>(
+ phase.kind === "dragging" ? phase.id : null,
+ );
+ const draggedPlacement = $derived(
+ draggedId !== null
+ ? (placements.find((placement) => placement.id === draggedId) ?? null)
+ : null,
+ );
+
+ /** The pointer's hovered cell while dragging (else null). */
+ const hoveredCell = $derived<Cell | null>(
+ phase.kind === "dragging" ? phase.targetCell : null,
+ );
+
+ /** The landing cell: where the pointer is, else the tile's committed slot. */
+ const targetCell = $derived<Cell | null>(
+ hoveredCell ??
+ (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null),
+ );
+
+ /**
+ * What to render: while dragging, the displacement of the COMMITTED layout
+ * at the target cell — recomputed from committed on EVERY hover change (as
+ * if the user moved straight from the original to here); the FLIP below
+ * animates from wherever the tiles currently are. Otherwise the committed
+ * placements.
+ */
+ const rendered = $derived(
+ draggedPlacement && targetCell && draggedId !== null
+ ? displacePlace(placements, draggedId, targetCell, geometry)
+ : placements,
+ );
+
+ // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ───
+ let tileEls = new Map<string, HTMLElement>();
+
+ function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
+ tileEls.set(id, el);
+ return {
+ destroy() {
+ tileEls.delete(id);
+ },
+ };
+ }
+
+ let flipState: ReturnType<typeof Flip.getState> | null = null;
+
+ $effect.pre(() => {
+ // Track the layout inputs; the DOM read below is intentionally untracked.
+ void rendered
+ .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`)
+ .join("|");
+ void geometry.mode;
+ flipState = Flip.getState(
+ [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el),
+ );
+ });
+
+ $effect(() => {
+ void rendered;
+ void geometry.mode;
+ const state = flipState;
+ flipState = null;
+ if (!state) return;
+ Flip.from(state, {
+ duration: 0.25,
+ // Fast start, decelerating arrival — the reflow sweeps out and settles
+ // into the destination (an inOut curve would ease INTO the motion too).
+ ease: "power3.out",
+ nested: true,
+ onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }),
+ });
+ });
+
+ // ── 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, the content changes, or a drag
+ // starts/ends (the canvas grows with the preview and shrinks back after).
+ void geometry.mode;
+ void placements.length;
+ void dragging;
+ 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 dragging = $derived(phase.kind === "dragging");
+
+ const gridStyle = $derived.by(() => {
+ const wide = geometry.mode === "wide";
+ // Content-driven canvas: the grid hugs its content (max-content / auto),
+ // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when
+ // the gesture ends — the drag sees the scrollbar move as it should. The
+ // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview
+ // can extend, so the canvas never grows past the committed edge + 3.
+ const base =
+ (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;`);
+ if (!dragging) return base;
+ // The drop-target dots: one faint dot at each unit cell's CENTER, painted
+ // as a background whose tile is the cell pitch. One extra pitch of padding
+ // past the content end gives the append-slot a dot too.
+ const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`;
+ const dotX = wide ? geometry.unit.width / 2 : 0;
+ const dot =
+ ` background-image: radial-gradient(circle 2.5px,` +
+ ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` +
+ ` background-size: ${dotSize} ${pitchY}px;` +
+ ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` +
+ (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`);
+ return base + dot;
+ });
+
+ 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};`;
+ }
+
+ /**
+ * The landing cell's top-left in CLIENT coordinates — where the CSS grid
+ * actually places a tile anchored at `targetCell` (the true column pitch:
+ * fixed in wide; the fluid column width PLUS the gap in tall). The follow
+ * transform cancels exactly this, so the grab point tracks the pointer.
+ */
+ const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => {
+ const el = gridEl;
+ if (!el || !targetCell) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols;
+ return {
+ x: rect.left + (targetCell.col - 1) * colPitch,
+ y: rect.top + (targetCell.row - 1) * pitchY,
+ };
+ });
+
+ /** The dragged tile's follow transform (it keeps its committed slot). */
+ function followStyle(placement: Placement): string {
+ if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") {
+ return "";
+ }
+ const origin = targetCellOrigin;
+ if (!origin) return "";
+ // Pointer minus the tile's GRID anchor minus the grab offset — the grab
+ // point the user pressed tracks the pointer. A raw pointer-displacement
+ // translate would ride the tile's origin instead and jump by the grab
+ // offset (a center grab snaps the tile's top-left under the pointer).
+ const dx = phase.x - origin.x - grabOffset.x;
+ const dy = phase.y - origin.y - grabOffset.y;
+ return `transform: translate(${dx}px, ${dy}px) scale(1.05);`;
+ }
+
+ // ── The drop ghost: a dashed footprint at the target anchor ───────────────
+ const ghostStyle = $derived.by(() => {
+ if (!dragging || !targetCell || !draggedPlacement) return null;
+ const span = spanFor(draggedPlacement);
+ const wide = geometry.mode === "wide";
+ const h = span.rowSpan * pitchY - geometry.unit.gap;
+ const x = wide
+ ? `${(targetCell.col - 1) * pitchX}px`
+ : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`;
+ const width = wide
+ ? `${span.colSpan * pitchX - geometry.unit.gap}px`
+ : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`;
+ return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`;
+ });
+
+ /**
+ * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
+ * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
+ * tone's SATURATED color with the tone's `-content` text, like Win8's
+ * colored tiles on the dark Start canvas.
+ */
+ const TONE_CLASSES: Record<TileTone, string> = {
+ primary: "bg-primary text-primary-content border-primary",
+ secondary: "bg-secondary text-secondary-content border-secondary",
+ accent: "bg-accent text-accent-content border-accent",
+ info: "bg-info text-info-content border-info",
+ success: "bg-success text-success-content border-success",
+ warning: "bg-warning text-warning-content border-warning",
+ };
+</script>
+
+<svelte:window onkeydowncapture={windowKeydown} />
+
+<div class="relative h-full">
+ <div
+ bind:this={scrollerEl}
+ class="h-full overscroll-contain"
+ class:overflow-x-auto={geometry.mode === "wide"}
+ class:overflow-y-auto={geometry.mode === "tall"}
+ style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`}
+ onscroll={updateScroll}
+ onwheel={handleWheel}
+ >
+ <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) -->
+ <div
+ bind:this={gridEl}
+ class="relative grid"
+ style={gridStyle}
+ onpointerdown={gridPointerDown}
+ onpointermove={gridPointerMove}
+ onpointerup={gridPointerUp}
+ onpointercancel={() => dispatch({ type: "cancel" })}
+ >
+ {#if ghostStyle && draggedPlacement}
+ <!-- The landing footprint (the dragged tile's tone, dashed). -->
+ <div
+ aria-hidden="true"
+ class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70"
+ class:border-primary={tileTone(draggedPlacement.id) === "primary"}
+ class:border-secondary={tileTone(draggedPlacement.id) === "secondary"}
+ class:border-accent={tileTone(draggedPlacement.id) === "accent"}
+ class:border-info={tileTone(draggedPlacement.id) === "info"}
+ class:border-success={tileTone(draggedPlacement.id) === "success"}
+ class:border-warning={tileTone(draggedPlacement.id) === "warning"}
+ style={ghostStyle}
+ ></div>
+ {/if}
+ {#each rendered as placement (placement.id)}
+ {@const ws = workspacesById.get(placement.id)}
+ {#if ws}
+ {@const isDragged = draggedId === placement.id}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <div
+ data-tile-id={placement.id}
+ class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${
+ TONE_CLASSES[tileTone(placement.id)]
+ } ${
+ selectedId === placement.id
+ ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
+ : "border-transparent"
+ } ${
+ isDragged
+ ? "pointer-events-none relative z-40 shadow-2xl"
+ : "hover:-translate-y-0.5 hover:shadow-xl"
+ }`}
+ style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`}
+ role="button"
+ tabindex="0"
+ aria-label={`Open workspace ${ws.title}`}
+ use:trackTile={placement.id}
+ 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).
+ Both sit INSIDE the canvas' outer margin. -->
+ <button
+ type="button"
+ class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl"
+ style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`}
+ aria-label="Pan right"
+ title="Pan one screen sideways"
+ onclick={pan}
+ >
+ →
+ </button>
+ <div
+ class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10"
+ style={`margin: 0 ${geometry.margin}px;`}
+ >
+ <div
+ class="h-full bg-primary transition-[width] duration-100"
+ style={`width: ${Math.round(scrollProgress * 100)}%;`}
+ ></div>
+ </div>
+ {/if}
+</div>
diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts
new file mode 100644
index 0000000..a8365d5
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.test.ts
@@ -0,0 +1,200 @@
+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,
+ archived: 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(),
+ onPlace: 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 ghost + follow transform, and dropping PLACES at the hovered cell", 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" });
+ // The cell math resolves against the grid element's rect (jsdom: 0s).
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ // The dashed landing ghost appears…
+ const ghost = container.querySelector("[aria-hidden='true'].border-dashed");
+ expect(ghost).not.toBeNull();
+ // …and the dragged tile itself follows the pointer (transform on the tile).
+ const tile = container.querySelector("[data-tile-id='a']");
+ expect(tile?.getAttribute("style")).toContain("translate(");
+
+ fireEvent.pointerUp(wrapper);
+ // The ghost + follow disappear on drop…
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ // …and the drop committed a PLACE at the hovered cell (a lands where it
+ // already was — the pointer hovered its own slot; a no-move drop).
+ expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 });
+ // And no navigation either (a drag is not a tap).
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => {
+ const props = baseProps();
+ const { container } = 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.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(props.onPlace).not.toHaveBeenCalled();
+ // The drag visuals are gone (the tile is no longer lifted).
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ });
+
+ 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..ffb8f1d
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.svelte
@@ -0,0 +1,73 @@
+<script lang="ts">
+ import { fly } from "svelte/transition";
+ import type { WorkspaceEntry } from "@dispatch/wire";
+
+ let {
+ ws,
+ withPlugins = false,
+ onOpen,
+ onRename,
+ onResize,
+ onToggleStar,
+ onDetails,
+ onPlugins,
+ onArchive,
+ 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;
+ /** Archive the workspace (hide from the home grid; reversible). */
+ onArchive: () => 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" onclick={onArchive}>Archive</button>
+ <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..c00de19
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.test.ts
@@ -0,0 +1,93 @@
+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,
+ archived: 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(),
+ onArchive: 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: "Archive" })).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: "Archive" }));
+ 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.onArchive).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 6de4109..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ /dev/null
@@ -1,279 +0,0 @@
-<script lang="ts">
- import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
- import type { WorkspaceStore } from "../store.svelte";
- import { relativeTime } from "../logic/view-model";
- import { workspacePath } from "../logic/route";
- import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
-
- let {
- ws,
- store,
- onNavigate,
- computers,
- hasActive,
- }: {
- 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;
- } = $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;
- }
- }
-
- // ── 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>
- </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>
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..9138742
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts
@@ -0,0 +1,115 @@
+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,
+ archived: 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..b101192
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.svelte
@@ -0,0 +1,146 @@
+<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}`);
+
+ // Span-aware face: bigger footprints get more air + bigger type (all styles
+ // static so Tailwind sees them).
+ const wide = $derived(placement.colSpan > 1);
+ const tall = $derived(placement.rowSpan > 1);
+ const facePadding = $derived(wide || tall ? "p-5" : "p-4");
+ const titleClass = $derived(
+ wide && tall
+ ? "text-3xl leading-tight line-clamp-3"
+ : wide
+ ? "text-2xl leading-snug line-clamp-2"
+ : tall
+ ? "text-xl leading-snug line-clamp-4"
+ : "text-lg leading-snug line-clamp-3",
+ );
+ /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */
+ const countLabel = $derived(
+ ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.",
+ );
+</script>
+
+<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}>
+ <!-- The slug label at the very top — the Win8 tile's small "app" caption. -->
+ <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase">
+ <span class="truncate opacity-70">/{ws.id}</span>
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span>
+ {/if}
+ </div>
+
+ {#if ws.starred}
+ <!-- Starred is a badge, not a tint — the tone stays the tile's identity. -->
+ <span
+ class="absolute top-2.5 right-3 text-lg leading-none opacity-90"
+ aria-label="Starred workspace"
+ title="Starred — priority in the agent queue">★</span
+ >
+ {/if}
+
+ {#if editing}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50"
+ 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={`mt-1 cursor-default font-bold ${titleClass}`}
+ title="Double-click to rename"
+ ondblclick={startEdit}>{ws.title}</span
+ >
+ {/if}
+
+ {#if titleError}
+ <p class="mt-1 text-xs font-semibold">{titleError}</p>
+ {/if}
+
+ <!-- The stat line pinned to the tile's bottom edge, like a live tile's
+ status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). -->
+ <div
+ class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap"
+ >
+ <span class="min-w-0 truncate">
+ {ws.conversationCount}
+ {countLabel}
+ </span>
+ <span class="ml-auto shrink-0 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..420791a
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.test.ts
@@ -0,0 +1,171 @@
+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,
+ archived: 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();
+ // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out).
+ expect(screen.getByText(/3 conv\./)).toBeInTheDocument();
+ expect(screen.getByText("1m")).toBeInTheDocument();
+ });
+
+ it("spells out 'conversations' on a wide tile", () => {
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement({ colSpan: 2, rowSpan: 1 }),
+ onRenameEnd: () => {},
+ },
+ });
+ expect(screen.getByText(/3 conversations/)).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 d97eab7..196e831 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,110 +1,363 @@
<script lang="ts">
- import { onMount } from "svelte";
+ import { onMount, untrack } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
+ import {
+ PluginManager,
+ WorkspacePluginModal,
+ type PluginsStore,
+ type WorkspaceLayoutPort,
+ } from "../../plugins";
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,
+ mergeLayout,
+ pack,
+ type GridGeometry,
+ type Span,
+ } from "../logic/tile-layout";
+ import { 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";
+ import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte";
let {
store,
onNavigate,
computers,
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 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 the per-workspace plugins
+ * modal's "Reset layout to default" affordance. Absent → the modal's
+ * layout section doesn't render.
+ */
+ layout?: WorkspaceLayoutPort;
+ /**
+ * The synced tile layout store (ONE shared document per device
+ * orientation, persisted by the backend's home-tile-layout plugin).
+ * Created by the page entry (App.svelte — the composition root that owns
+ * the httpBase); tests inject one.
+ */
+ tiles: TileLayoutStore;
} = $props();
onMount(() => {
void store.refresh();
});
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ // The store arrives fully wired (the page entry created it over the real
+ // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf.
+ // App.svelte) dealiases the prop once — the component instance is the
+ // store's single owner from here on.
+ const tiles: TileLayoutStore = untrack(() => tilesOverride);
+
+ // 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 guards inside sync keep 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). Start from the viewport, matching the boot probe;
+ // the canvas measurement then refines it (no transient wide reset on phones).
+ const initialViewport = typeof window === "undefined"
+ ? DEFAULT_VIEWPORT
+ : { width: window.innerWidth, height: window.innerHeight };
+ let regionEl = $state<HTMLDivElement | undefined>();
+ let size = $state<{ width: number; height: number } | undefined>();
+ const geometry: GridGeometry = $derived(
+ geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.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();
+ });
+
+ // Lock the store's active orientation to the resolved grid geometry: the
+ // landscape/portrait split IS the device-type split — every landscape
+ // device shares the `wide` document, every portrait device the `tall` one.
+ // Same-mode calls are a no-op inside the store; an orientation change
+ // resets + re-probes (never flashing the other orientation's arrangement).
+ $effect(() => {
+ tiles.setMode(geometry.mode);
+ });
+
+ // The grid view: the SAME persisted data packs into either geometry —
+ // positions re-derive here, spans clamp for the RENDER only.
+ // Render-only fallback: workspace discovery must not wait for layout GET.
+ // Never sync/commit this provisional arrangement while the probe is unknown.
+ const entries = $derived(tiles.entries.length > 0
+ ? tiles.entries
+ : mergeLayout(null, store.list.map((ws) => ws.id)));
+ const placements = $derived(pack(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;
+ }
+
+ // ── 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,
+ );
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ // ── Per-workspace plugin modal ─────────────────────────────────────────────
+ let pluginsModalId = $state<string | null>(null);
+ const pluginsModalFor = $derived(
+ pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null,
+ );
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ // Global plugin manager modal (plan 07 §7.2): the ONLY surface that
+ // loads/unloads code (strict layering).
+ let managerOpen = $state(false);
+
+ // ── Archived workspaces modal ──────────────────────────────────────────────
+ let archivedOpen = $state(false);
+
+ // ── 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);
+ }
+
+ async function archiveSelected(): Promise<void> {
+ if (!selected) return;
+ const ws = selected;
+ // Reversible without a confirm dialog — the workspace is hidden, not deleted.
+ const result = await store.setArchived(ws.id, true);
+ if (result.ok) deselect();
+ }
+
+ // ── Create (header popover): check the slug resolves to no archived
+ // workspace, 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.create(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>
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- </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="h-app flex 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()}
+ onOpenArchived={() => (archivedOpen = true)}
+ />
+
+ <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 class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-lg 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.
- </p>
+ <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
+ <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p>
+ <p class="max-w-md text-sm opacity-60">
+ Create one with <strong>New workspace</strong> above, or visit
+ <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first
+ tile drops onto the canvas ready to rename.
+ </p>
+ </div>
{:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard
- {ws}
- {store}
- {onNavigate}
- {computers}
- {...(hasActive ? { hasActive } : {})}
- />
- {/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}
+ onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)}
+ 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)}
+ onArchive={() => void archiveSelected()}
+ 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}
+
+{#if archivedOpen}
+ <ArchivedWorkspaces
+ {store}
+ onOpen={(id) => openWorkspace(id)}
+ onClose={() => (archivedOpen = 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..5a66a42
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -0,0 +1,333 @@
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+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 { WorkspaceHttp, WorkspaceResult } from "../adapter/http";
+import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http";
+import type { WorkspaceLookup } from "../logic/create";
+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,
+ archived: 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()],
+ lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }),
+): 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(lookup),
+ 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;
+}
+
+/**
+ * A fake tile-layout HTTP edge serving ONE controllable document per mode
+ * (no mocks of our modules). `docs` is the live store — assertions read it
+ * after `waitFor` (the boot probe + debounced sync writes land there).
+ */
+function memoryLayout(
+ initial: TileLayoutData | null = null,
+): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } {
+ const docs = new Map<TileGeometryMode, TileLayoutData>();
+ if (initial !== null) docs.set("wide", initial);
+ return {
+ docs,
+ async getLayout(mode) {
+ return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } };
+ },
+ async putLayout(mode, layout) {
+ docs.set(mode, layout);
+ return { ok: true, layout };
+ },
+ async deleteLayout(mode) {
+ docs.set(mode, { version: 2, tiles: [] });
+ return { ok: true, layout: docs.get(mode) as TileLayoutData };
+ },
+ };
+}
+
+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: 2,
+ 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({ http: 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.each([
+ false,
+ true,
+ ])("renders before the layout probe resolves (cached: %s)", async (cached) => {
+ const layout = memoryLayout();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ layout.getLayout = vi.fn(
+ () =>
+ new Promise<TileLayoutHttpResult>((resolve) => {
+ gate.release = resolve;
+ }),
+ );
+ const put = vi.spyOn(layout, "putLayout");
+ const stored = {
+ version: 2,
+ tiles: [
+ { id: "b", order: 0, colSpan: 2, rowSpan: 2 },
+ { id: "a", order: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ };
+ const storage = cached ? localStorage : undefined;
+ storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored));
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(
+ fakeHttp([ws(), ws({ id: "b", title: "B" })]),
+ ),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await screen.findByRole("button", { name: "Open workspace B" });
+ expect(
+ [...document.querySelectorAll("[data-tile-id]")].map((el) =>
+ el.getAttribute("data-tile-id"),
+ ),
+ ).toEqual(cached ? ["b", "a"] : ["a", "b"]);
+ expect(tiles.providerState).toBe("unknown");
+ expect(tiles.entries).toEqual(cached ? stored.tiles : []);
+ expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall");
+ expect(put).not.toHaveBeenCalled();
+ gate.release?.({
+ ok: true,
+ layout: {
+ version: 2,
+ tiles: stored.tiles
+ .slice()
+ .reverse()
+ .map((tile, order) => ({ ...tile, order })),
+ },
+ });
+ await waitFor(() => {
+ expect(tiles.providerState).toBe("present");
+ expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]);
+ // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put.
+ expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1");
+ expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2");
+ });
+ } finally {
+ width.mockRestore();
+ height.mockRestore();
+ storage?.removeItem("dispatch.tile-layout.tall");
+ }
+ });
+
+ it("re-probes when the canvas changes orientation", async () => {
+ let measure: ResizeObserverCallback = () => {};
+ const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => {
+ measure = callback;
+ return { observe() {}, unobserve() {}, disconnect() {} };
+ });
+ const layout = memoryLayout();
+ const get = vi.spyOn(layout, "getLayout");
+ const tiles = createTileLayoutStore({ http: layout });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ measure(
+ [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[],
+ {} as ResizeObserver,
+ );
+ await waitFor(() => {
+ expect(tiles.mode).toBe("tall");
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ } finally {
+ observer.mockRestore();
+ }
+ });
+
+ 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({ http: 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({ http: 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("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const http = fakeHttp(list, async (id) => ({
+ kind: "found",
+ workspace: ws({ id, title: id, archived: true }),
+ }));
+ const store = (await import("../store.svelte")).createWorkspaceStore(http);
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: 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" }), "old-ws{Enter}");
+
+ expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument();
+ // The popover stays open (the form/input is still mounted).
+ expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument();
+ // Nothing was ensured, no tile appeared, and no rename mode was entered.
+ expect(http.ensure).not.toHaveBeenCalled();
+ expect(store.list.map((w) => w.id)).toEqual(["a"]);
+ expect(screen.queryByLabelText("Workspace title")).toBeNull();
+ });
+
+ 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({ http: 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: 2,
+ 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({ http: 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.
+ await waitFor(() => {
+ const tiles = layout.docs.get("wide")?.tiles ?? [];
+ expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]);
+ });
+ });
+});