From 754450b539818f9ad94c9586888973b0edd74b68 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 7 Sep 2026 11:47:18 +0900 Subject: feat(workspaces): Win8 Start-style tile-grid home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/workspaces/ui/WorkspaceTile.test.ts | 157 +++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/features/workspaces/ui/WorkspaceTile.test.ts (limited to 'src/features/workspaces/ui/WorkspaceTile.test.ts') diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..918aa1c --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,157 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceTile from "./WorkspaceTile.svelte"; + +function fakeEntry(overrides: Partial = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: Date.now() - 90_000, // "1m" relative time + conversationCount: 3, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn( + async (id: string, title: string): Promise> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial = {}): Placement { + return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides }; +} + +describe("WorkspaceTile", () => { + it("renders the title, slug, conversation count, and relative last-activity", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + expect(screen.getByText("1m")).toBeInTheDocument(); + }); + + it("shows the active indicator only when the active port says so", async () => { + const { container, rerender } = render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: true, + onRenameEnd: () => {}, + }, + }); + expect( + container.querySelector('[aria-label="Workspace has active conversations"]'), + ).not.toBeNull(); + + await rerender({ + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: false, + onRenameEnd: () => {}, + }); + expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull(); + }); + + it("double-clicking the title reveals the rename input (semantics preserved)", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("the parent-driven rename mode (action bar) opens the same inline editor", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + const input = screen.getByLabelText("Workspace title"); + expect(input).toHaveValue("My Workspace"); + + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("a failed rename surfaces the error on the face", async () => { + const user = userEvent.setup(); + const store = { + rename: vi.fn( + async (): Promise> => ({ + 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(); + }); +}); -- cgit v1.2.3