summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/WorkspaceTile.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/WorkspaceTile.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/WorkspaceTile.svelte')
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.svelte109
1 files changed, 109 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte
new file mode 100644
index 0000000..4059690
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.svelte
@@ -0,0 +1,109 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import type { Placement } from "../logic/tile-layout";
+
+ let {
+ ws,
+ store,
+ placement,
+ selected = false,
+ renaming = false,
+ active = false,
+ onRenameEnd,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ /** The tile's resolved footprint (already preview-adjusted by the grid). */
+ placement: Placement;
+ selected?: boolean;
+ /** The parent-driven rename mode (the action bar's Rename action). */
+ renaming?: boolean;
+ /** At least one conversation in this workspace is generating/queued. */
+ active?: boolean;
+ /** The tile finished its rename session (saved or cancelled). */
+ onRenameEnd: () => void;
+ } = $props();
+
+ // ── Title: double-click to rename inline (semantics preserved from the
+ // WorkspaceCard), or enter via the action bar's Rename action (`renaming`).
+ let editing = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEdit(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editing = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ // Seed (only) when the parent-driven rename mode turns on.
+ $effect(() => {
+ if (renaming) startEdit();
+ });
+
+ async function saveTitle(): Promise<void> {
+ if (!editing) return;
+ const title = titleDraft.trim();
+ editing = false;
+ onRenameEnd();
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editing = false;
+ titleError = null;
+ onRenameEnd();
+ }
+
+ // The face's semantic key (tests read it).
+ const faceKey = $derived(`tile-${placement.id}`);
+</script>
+
+<div data-tile-face={faceKey} class="flex h-full w-full flex-col gap-1 p-3">
+ {#if editing}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") void saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={() => void saveTitle()}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="line-clamp-2 cursor-default text-lg leading-tight font-bold"
+ title="Double-click to rename"
+ ondblclick={startEdit}>{ws.title}</span
+ >
+ {/if}
+
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="mt-auto flex items-center gap-2 text-xs opacity-80">
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0 text-primary"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span>
+ {/if}
+ <span>
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ </span>
+ <span class="ml-auto font-mono opacity-70">{relativeTime(ws.lastActivityAt, Date.now())}</span>
+ </div>
+</div>