summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 01:04:35 +0900
committerAdam Malczewski <[email protected]>2026-10-21 01:04:35 +0900
commitc61b4d66921e7fe5d7d1e2a988e96785f6aedd9f (patch)
tree8130a35661a66d28197f5f52fff036a6be523656 /src/features/workspaces/ui
parentb3a539260aa15efd197ce06c1073f0f25fee0c15 (diff)
downloaddispatch-web-c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f.tar.gz
dispatch-web-c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f.zip
fix(review-1.3): render home tiles before layout revalidation
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte16
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts106
2 files changed, 118 insertions, 4 deletions
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 65e1593..196e831 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -12,6 +12,7 @@
import {
DEFAULT_VIEWPORT,
geometryFor,
+ mergeLayout,
pack,
type GridGeometry,
type Span,
@@ -86,11 +87,15 @@
});
// ── The tile canvas' size decides the geometry (ONE pure function — no
- // breakpoint classes). Unmeasured (tests) → the default landscape viewport.
+ // 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 ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height),
+ geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height),
);
$effect(() => {
@@ -115,7 +120,12 @@
// 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));
+ // 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 ─────────────────────────────────────────────────
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
index d4316b1..5a66a42 100644
--- a/src/features/workspaces/ui/WorkspacesHome.test.ts
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -4,7 +4,7 @@ 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 } from "../adapter/tile-layout-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";
@@ -101,6 +101,110 @@ describe("WorkspacesHome (tile canvas)", () => {
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();