summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/WorkspacesHome.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
committerAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
commit754450b539818f9ad94c9586888973b0edd74b68 (patch)
treed852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui/WorkspacesHome.svelte
parent719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff)
downloaddispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz
dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid, replacing the max-w-3xl vertical card list. Pure core (zero DOM/Svelte/fetch, fully unit-tested): - logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed ~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns, row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform (greedy first-fit in fill order, hole-free, render-clamped per geometry); firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/ sortByRecentActivity/sanitizeLayout. Persisted data is orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} — order + spans only, never positions; the same data renders in either geometry (spans clamp for the render, persisted values untouched). - logic/drag-controller.ts — pure pointer/press state machine: mouse threshold-drag + tap-open; touch long-press lifts + selects, pre-lift movement cancels so vertical scrolling never fights the gesture. Shell: - tile-layout-store.svelte.ts — thin reactive wrapper persisting through the injected createLocalStore adapter (dispatch.workspaces.tiles), ops: sync/resize/move/placeFirstEmpty/sortByRecentActivity. - ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll + snap + wheel pan + pan arrow + scroll indicator; tall: native vertical scroll), FLIP-animated reflow, drag ghost, transient resize preview. - ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New workspace popover (same slug validation; creates via create-on-miss and does NOT navigate — the new tile lands in the first empty slot in fill order and enters rename mode), global Plugins button, sort-by-recent overflow; compact icon buttons + bottom sheet on tall screens. - ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count, relative last-activity, live active dots, starred tint); inline rename with the old card's semantics. - ui/TileActionBar.svelte — slides up on select (right-click / long-press): Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm + store call preserved). - ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 / tall 2x2) with arrow/Enter/Escape support and ghost preview on commit. - ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd input + default-computer select (same store calls + error states). - WorkspacesHome.svelte orchestrates; App.svelte untouched (same props). - WorkspaceCard.svelte is removed (replaced by the tile system). Manual tile order rules; starred workspaces get a tint (star is no longer a sort key); workspaces unknown to the layout append at the end. vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate stubs — the pointer logic is the outermost edge for the drag tests. Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice), biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui/WorkspacesHome.svelte')
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte363
1 files changed, 270 insertions, 93 deletions
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 5a4d88f..276a811 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,10 +1,32 @@
<script lang="ts">
- import { onMount } from "svelte";
+ import { onMount, untrack } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
- import { PluginManager, type PluginsStore, type WorkspaceLayoutPort } from "../../plugins";
+ import {
+ PluginManager,
+ WorkspacePluginModal,
+ type PluginsStore,
+ type WorkspaceLayoutPort,
+ } from "../../plugins";
+ import { createLocalStore } from "../../../adapters/local-storage";
import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { workspacePath } from "../logic/route";
+ import {
+ DEFAULT_VIEWPORT,
+ geometryFor,
+ pack,
+ type GridGeometry,
+ type Span,
+ } from "../logic/tile-layout";
+ import {
+ createTileLayoutStore,
+ TILE_LAYOUT_STORAGE_KEY,
+ type TileLayoutStore,
+ } from "../tile-layout-store.svelte";
+ import HomeHeader from "./HomeHeader.svelte";
+ import HomeTileGrid from "./HomeTileGrid.svelte";
+ import TileActionBar from "./TileActionBar.svelte";
+ import ResizePicker from "./ResizePicker.svelte";
+ import WorkspaceDetails from "./WorkspaceDetails.svelte";
let {
store,
@@ -13,136 +35,291 @@
hasActive,
plugins,
layout,
+ tiles: tilesOverride,
}: {
store: WorkspaceStore;
onNavigate: (path: string) => void;
computers: readonly ComputerEntry[];
/**
- * Optional port forwarded to each {@link WorkspaceCard}: whether the
- * workspace has at least one active (generating / queued) conversation.
+ * Optional port: whether the workspace has at least one active
+ * (generating / queued) conversation — the tile's live indicator.
* Wired by the composition root to the app store. Absent → no indicator.
*/
hasActive?: (workspaceId: string) => boolean;
/**
- * The plugin manager store (plan 07): drives the page-header global
- * "Plugins" button + the per-card workspace modals (forwarded to
- * {@link WorkspaceCard}). Optional (absent in tests that don't exercise
- * plugins) → the buttons don't render.
+ * The plugin manager store (plan 07): drives the header's global
+ * "Plugins" button + the action bar's per-workspace Plugins action.
+ * Optional (absent in tests that don't exercise plugins).
*/
plugins?: PluginsStore;
/**
- * The layout port (plan 04 §4.4): forwarded to each {@link WorkspaceCard}
- * for the plugins modal's "Reset layout to default" affordance. Absent →
- * the modal's layout section doesn't render.
+ * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins
+ * modal's "Reset layout to default" affordance. Absent → the modal's
+ * layout section doesn't render.
*/
layout?: WorkspaceLayoutPort;
+ /**
+ * The persisted tile layout store. Absent → created here over the real
+ * localStorage adapter (the page is the effect edge); tests inject one.
+ */
+ tiles?: TileLayoutStore;
} = $props();
onMount(() => {
void store.refresh();
});
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ // ── The persisted tile layout (order + spans only — never positions). ─────
+ // The store is created once per page from the (inert) override prop — the
+ // repo's `untrack` pattern for initial-value captures (cf. App.svelte).
+ const tiles: TileLayoutStore =
+ untrack(() => tilesOverride) ??
+ createTileLayoutStore(
+ createLocalStore(TILE_LAYOUT_STORAGE_KEY, {
+ storage: typeof localStorage !== "undefined" ? localStorage : undefined,
+ }),
+ );
+
+ // Keep the layout merged with the live workspace list: unknown workspaces
+ // (deep-link auto-creates) append at the end; deleted ones drop. The no-op
+ // guard inside sync keeps this effect from looping. Skipped until the first
+ // non-empty list arrives — `sync([])` before the initial `refresh()` resolves
+ // would WIPE the persisted order (the backend always serves "default", so an
+ // empty list only ever means "not loaded yet").
+ $effect(() => {
+ const ids = store.list.map((ws) => ws.id);
+ if (ids.length === 0) return;
+ tiles.sync(ids);
+ });
+
+ // ── The tile canvas' size decides the geometry (ONE pure function — no
+ // breakpoint classes). Unmeasured (tests) → the default landscape viewport.
+ let regionEl = $state<HTMLDivElement | undefined>();
+ let size = $state<{ width: number; height: number } | undefined>();
+ const geometry: GridGeometry = $derived(
+ geometryFor(size?.width ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height),
+ );
+
+ $effect(() => {
+ const el = regionEl;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ const rect = entries[0]?.contentRect;
+ if (rect) size = { width: rect.width, height: rect.height };
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ });
+
+ // The grid view: the SAME persisted data packs into either geometry —
+ // positions re-derive here, spans clamp for the RENDER only.
+ const placements = $derived(pack(tiles.entries, geometry));
+ const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws])));
+
+ // ── Selection + action bar ─────────────────────────────────────────────────
+ let selectedId = $state<string | null>(null);
+ const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null);
+
+ function select(id: string): void {
+ selectedId = id;
+ }
+
+ function deselect(): void {
+ selectedId = null;
+ }
+
+ // ── Rename mode (the create flow + the action bar's Rename action) ────────
+ let renamingId = $state<string | null>(null);
+
+ // ── Resize picker (transient ghost preview; commit persists) ──────────────
+ let resizing = $state<{ id: string; span: Span | null } | null>(null);
+ const resizingEntry = $derived(
+ resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null,
+ );
+
+ function startResize(): void {
+ if (!selected) return;
+ const entry = tiles.entries.find((tile) => tile.id === selected.id);
+ resizing = {
+ id: selected.id,
+ span: entry
+ ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan }
+ : { colSpan: 1, rowSpan: 1 },
+ };
+ }
+
+ function commitResize(colSpan: number, rowSpan: number): void {
+ if (!resizing) return;
+ tiles.resize(resizing.id, colSpan, rowSpan);
+ resizing = null;
+ }
+
+ function cancelResize(): void {
+ resizing = null;
+ }
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ // ── Details flyout (the dense per-card settings). Id-based + gated on the
+ // selection, so deselecting (Escape) also dismisses it. ─────────────────────
+ let detailsForId = $state<string | null>(null);
+ const detailsFor = $derived(
+ selectedId !== null && detailsForId === selectedId
+ ? (workspacesById.get(selectedId) ?? null)
+ : null,
+ );
- // Global plugin manager modal (plan 07 §7.2): opened from the header
- // button; the ONLY surface that loads/unloads code (strict layering).
+ // ── Per-workspace plugin modal ─────────────────────────────────────────────
+ let pluginsModalId = $state<string | null>(null);
+ const pluginsModalFor = $derived(
+ pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null,
+ );
+
+ // Global plugin manager modal (plan 07 §7.2): the ONLY surface that
+ // loads/unloads code (strict layering).
let managerOpen = $state(false);
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ // ── Actions (the action bar) ───────────────────────────────────────────────
+ function openWorkspace(id: string): void {
+ deselect();
+ onNavigate(workspacePath(id));
+ }
+
+ async function deleteSelected(): Promise<void> {
+ if (!selected) return;
+ const ws = selected;
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
return;
}
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
+ const result = await store.remove(ws.id);
+ if (result.ok) deselect();
+ }
+
+ async function toggleStar(): Promise<void> {
+ if (!selected) return;
+ await store.setStarred(selected.id, !selected.starred);
+ }
+
+ // ── Create (header popover): create on the backend — do NOT navigate —
+ // place the new tile in the first empty slot, and go straight into rename.
+ async function createWorkspace(
+ slug: string,
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
+ const result = await store.ensure(slug);
+ if (!result.ok) return { ok: false, error: result.error };
+ await store.refresh();
+ // Idempotent: if the list refresh's sync already appended the id, this
+ // MOVES it into the first empty slot instead of duplicating.
+ tiles.placeFirstEmpty(slug, geometry);
+ renamingId = slug;
+ return { ok: true };
+ }
+
+ // ── Sort overflow: re-apply the recent-activity order ──────────────────────
+ function sortByRecent(): void {
+ const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt]));
+ tiles.sortByRecentActivity(activity);
+ }
+
+ // Escape unwinds the overlays: resize → selection.
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape") return;
+ if (resizing) {
+ cancelResize();
+ } else if (selectedId !== null) {
+ deselect();
+ }
}
</script>
-<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
- <header class="flex items-center justify-between">
- <h1 class="text-2xl font-bold">Workspaces</h1>
- <div class="flex items-center gap-2">
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- {#if plugins}
- <button
- type="button"
- class="btn btn-outline btn-sm"
- onclick={() => (managerOpen = true)}
- title="Open the plugin manager (load/unload plugins globally)"
- >
- Plugins
- </button>
- {/if}
- </div>
- </header>
-
- <form
- class="flex items-end gap-2"
- onsubmit={(e) => {
- e.preventDefault();
- createWorkspace();
- }}
- >
- <div class="flex-1">
- <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60"
- >New workspace</label
- >
- <input
- id="new-ws"
- class="input input-bordered w-full"
- placeholder="my-workspace"
- bind:value={newSlug}
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
- </form>
- {#if slugError}
- <p class="text-xs text-error">{slugError}</p>
- {/if}
-
- <div class="flex-1 overflow-y-auto">
+<svelte:window onkeydown={handleKeydown} />
+
+<div class="flex h-screen w-full flex-col">
+ <HomeHeader
+ compact={geometry.mode === "tall"}
+ withPlugins={plugins !== undefined}
+ withSort={store.list.length > 1}
+ onCreate={createWorkspace}
+ onOpenPlugins={() => (managerOpen = true)}
+ onSort={() => void sortByRecent()}
+ />
+
+ <div bind:this={regionEl} class="min-h-0 w-full flex-1">
{#if store.loading && store.list.length === 0}
<div class="flex h-32 items-center justify-center">
<span class="loading loading-spinner loading-sm opacity-60"></span>
</div>
{:else if store.list.length === 0}
<p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ No workspaces yet. Create one with <strong>New workspace</strong> above, or visit
+ <code>/your-name</code> in the URL.
</p>
{:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard
- {ws}
- {store}
- {onNavigate}
- {computers}
- {...(hasActive ? { hasActive } : {})}
- {...(plugins ? { plugins } : {})}
- {...(layout ? { layout } : {})}
- />
- {/each}
- </ul>
+ <HomeTileGrid
+ {geometry}
+ {placements}
+ {workspacesById}
+ {store}
+ {selectedId}
+ {renamingId}
+ preview={
+ resizing?.id !== undefined && resizing.span !== null
+ ? { id: resizing.id, ...resizing.span }
+ : null
+ }
+ {...(hasActive ? { hasActive } : {})}
+ onOpen={openWorkspace}
+ onSelect={select}
+ onReorder={(id, toIndex) => tiles.move(id, toIndex)}
+ onRenameEnd={() => (renamingId = null)}
+ />
{/if}
</div>
</div>
+{#if selected && !renamingId}
+ <TileActionBar
+ ws={selected}
+ withPlugins={plugins !== undefined}
+ onOpen={() => openWorkspace(selected.id)}
+ onRename={() => (renamingId = selected.id)}
+ onResize={startResize}
+ onToggleStar={() => void toggleStar()}
+ onDetails={() => (detailsForId = selected.id)}
+ onPlugins={() => (pluginsModalId = selected.id)}
+ onDelete={() => void deleteSelected()}
+ onClose={deselect}
+ />
+{/if}
+
+{#if resizing && resizingEntry}
+ <ResizePicker
+ {geometry}
+ initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }}
+ onPreview={(span) => {
+ if (resizing) resizing = { id: resizing.id, span };
+ }}
+ onCommit={commitResize}
+ onCancel={cancelResize}
+ />
+{/if}
+
+{#if detailsFor}
+ <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} />
+{/if}
+
+{#if pluginsModalFor && plugins}
+ <WorkspacePluginModal
+ workspaceId={pluginsModalFor.id}
+ workspaceTitle={pluginsModalFor.title}
+ {plugins}
+ {...(layout ? { layout } : {})}
+ onClose={() => (pluginsModalId = null)}
+ />
+{/if}
+
{#if managerOpen && plugins}
<PluginManager {plugins} onClose={() => (managerOpen = false)} />
{/if}