summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte1
-rw-r--r--src/adapters/ws/index.ts9
-rw-r--r--src/adapters/ws/logic.ts19
-rw-r--r--src/app/store.svelte.ts33
-rw-r--r--src/core/wire/conformance.ts2
-rw-r--r--src/features/workspaces/adapter/tile-layout-http.ts117
-rw-r--r--src/features/workspaces/index.ts5
-rw-r--r--src/features/workspaces/tile-layout-store.svelte.ts222
-rw-r--r--src/features/workspaces/tile-layout-store.test.ts368
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte49
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts56
11 files changed, 704 insertions, 177 deletions
diff --git a/src/App.svelte b/src/App.svelte
index bc9367c..32eaf70 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -93,6 +93,7 @@
hasActive={(id) => store.workspaceHasActiveConversations(id)}
plugins={store.plugins}
layout={layoutPort}
+ tiles={store.tileLayout}
/>
{:else}
<App {store} onNavigate={navigate} />
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
index 3713c8c..4dc0104 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -6,6 +6,7 @@ import type {
ConversationStatusChangedMessage,
ExtensionsChangedMessage,
LayoutChangedMessage,
+ TileLayoutChangedMessage,
WsClientMessage,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -40,6 +41,12 @@ export interface SurfaceSocketOptions {
* tree for one workspace. The consumer filters by its active workspace.
*/
onLayout?: (msg: LayoutChangedMessage) => void;
+ /**
+ * Broadcast after every successful home tile-layout write: the FULL
+ * document for one device orientation (wide/tall). The consumer filters by
+ * its active geometry mode.
+ */
+ onTileLayout?: (msg: TileLayoutChangedMessage) => void;
onReopen?: () => void;
socketFactory?: (url: string) => WebSocketLike;
}
@@ -92,6 +99,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage);
} else if (msg.type === "layout") {
opts.onLayout?.(msg as LayoutChangedMessage);
+ } else if (msg.type === "tile-layout") {
+ opts.onTileLayout?.(msg as TileLayoutChangedMessage);
} else {
opts.onMessage(msg as SurfaceServerMessage);
}
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 6cbc559..4dbd173 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -6,6 +6,7 @@ import type {
ConversationStatusChangedMessage,
ExtensionsChangedMessage,
LayoutChangedMessage,
+ TileLayoutChangedMessage,
WsClientMessage,
WsServerMessage,
} from "@dispatch/transport-contract";
@@ -28,6 +29,7 @@ const VALID_SERVER_TYPES = new Set([
"conversation.compacted",
"extensions-changed",
"layout",
+ "tile-layout",
]);
/** Serialize a client message to a JSON string for the wire. */
@@ -206,6 +208,23 @@ export function parseServerMessage(data: string): WsServerMessage | null {
};
return msg;
}
+ case "tile-layout": {
+ // Home tile-layout broadcast (the synced home grid): the FULL document
+ // for ONE device orientation. Light shape checks here (the document's
+ // structural validation is the backend's write-time job); the store
+ // filters by its active geometry mode.
+ if (parsed.mode !== "wide" && parsed.mode !== "tall") return null;
+ const data = parsed.data;
+ if (data === null || !isRecord(data) || data.version !== 2 || !Array.isArray(data.tiles)) {
+ return null;
+ }
+ const msg: TileLayoutChangedMessage = {
+ type: "tile-layout",
+ mode: parsed.mode,
+ data: data as unknown as TileLayoutChangedMessage["data"],
+ };
+ return msg;
+ }
default:
return null;
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index c1d265f..a858927 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -34,6 +34,7 @@ import type {
SystemPromptVariable,
SystemPromptVariablesResponse,
TestComputerResponse,
+ TileLayoutChangedMessage,
WarmRequest,
WarmResponse,
WorkspaceSetMessage,
@@ -108,6 +109,9 @@ import {
type VisionSettings,
type VisionSettingsPatch,
} from "../features/vision";
+import { createTileLayoutHttp } from "../features/workspaces/adapter/tile-layout-http";
+import type { TileLayoutStore } from "../features/workspaces/tile-layout-store.svelte";
+import { createTileLayoutStore } from "../features/workspaces/tile-layout-store.svelte";
import { createPluginHost } from "./plugin-host.svelte";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
@@ -581,6 +585,15 @@ export interface AppStore {
*/
readonly layout: LayoutStore;
/**
+ * The home grid's synced tile layout (ONE shared document per device
+ * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile),
+ * served by the backend's home-tile-layout plugin. The home page locks the
+ * active orientation to the resolved grid geometry; this store owns the
+ * probes, the WS-push filter, the debounced PUT + rollback, and the
+ * offline degrade.
+ */
+ readonly tileLayout: TileLayoutStore;
+ /**
* Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
* (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
* first-boot guardrail renders only on a confident `0` (§7).
@@ -736,6 +749,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
knownViews: listPanelViews().map((v) => v.id),
});
+ // ── Home tile-layout (the synced Win8-Start grid) ─────────────────────────
+ // ONE shared document PER device orientation (`wide` = landscape/desktop,
+ // `tall` = portrait/mobile), persisted by the backend's home-tile-layout
+ // plugin and broadcast over WS (`tile-layout`). The home page locks the
+ // active orientation to the resolved grid geometry and renders
+ // `tileLayout.entries`; this store owns the probes, the WS-push filter,
+ // the debounced PUT + rollback, and the offline degrade.
+ const tileLayoutStore = createTileLayoutStore({
+ http: createTileLayoutHttp(httpBase, fetchImpl),
+ });
+
/**
* Refetch the model catalog for the ACTIVE workspace
* (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
@@ -1543,6 +1567,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// workspaces (the server fans out to everyone).
layoutStore.onServerTree(msg.workspaceId, msg.tree);
},
+ onTileLayout(msg: TileLayoutChangedMessage) {
+ // Multi-client coherence for the home grid's synced layouts: a write
+ // on ANY client re-renders here — the store filters the OTHER
+ // orientation (the server fans out to everyone).
+ tileLayoutStore.onServerTileLayout(msg.mode, msg.data);
+ },
};
if (opts?.socketFactory !== undefined) {
socketOpts.socketFactory = opts.socketFactory;
@@ -2670,6 +2700,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
get layout(): LayoutStore {
return layoutStore;
},
+ get tileLayout(): TileLayoutStore {
+ return tileLayoutStore;
+ },
get enabledProviderCount(): number | null {
return enabledProviderCount;
},
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 99afc6d..b358641 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -95,6 +95,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "extensions-changed";
case "layout":
return "layout";
+ case "tile-layout":
+ return "tile-layout";
default:
return msg satisfies never;
}
diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts
new file mode 100644
index 0000000..58af9d8
--- /dev/null
+++ b/src/features/workspaces/adapter/tile-layout-http.ts
@@ -0,0 +1,117 @@
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+
+/**
+ * Home tile-layout HTTP effects — the injected edge that talks to the
+ * `home-tile-layout` plugin's well-known routes:
+ * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads
+ * as the seeded empty document)
+ * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` /
+ * 400 `{ error }`
+ * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset)
+ *
+ * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl`
+ * (testable without the network). A 404 is NOT a "no document" signal (the
+ * backend never 404s a fresh orientation — it seeds): any non-2xx / network
+ * failure means "the routes aren't served" (an old backend) or "backend
+ * unreachable" — both degrade the store to its local state with ephemeral
+ * edits, never an error UI.
+ */
+
+/** Outcome of a tile-layout read/write. */
+export type TileLayoutHttpResult =
+ | { readonly ok: true; readonly layout: TileLayoutData }
+ | { readonly ok: false; readonly notFound: boolean; readonly error: string };
+
+export interface TileLayoutHttp {
+ getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>;
+ putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>;
+ deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>;
+}
+
+/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
+async function errorOf(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
+ } catch {
+ // fall through
+ }
+ return `HTTP ${res.status}`;
+}
+
+/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */
+function malformed(): TileLayoutHttpResult {
+ return { ok: false, notFound: false, error: "Malformed tile-layout response" };
+}
+
+function route(httpBase: string, mode: TileGeometryMode): string {
+ return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`;
+}
+
+export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp {
+ return {
+ async getLayout(mode): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode));
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+
+ async putLayout(mode, layout): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ layout }),
+ });
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+
+ async deleteLayout(mode): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" });
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index a921617..1a60bb7 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -1,5 +1,6 @@
export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
+export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http";
export type {
DragEmit,
DragEvent as DragControllerEvent,
@@ -42,8 +43,8 @@ export {
export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model";
export type { WorkspaceStore } from "./store.svelte";
export { createWorkspaceStore } from "./store.svelte";
-export type { TileLayoutStore } from "./tile-layout-store.svelte";
-export { createTileLayoutStore, TILE_LAYOUT_STORAGE_KEY } from "./tile-layout-store.svelte";
+export type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte";
+export { createTileLayoutStore } from "./tile-layout-store.svelte";
export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts
index 2ca66a4..a910c73 100644
--- a/src/features/workspaces/tile-layout-store.svelte.ts
+++ b/src/features/workspaces/tile-layout-store.svelte.ts
@@ -1,4 +1,8 @@
-import type { LocalStore } from "../../adapters/local-storage";
+import type {
+ TileLayoutData as ContractTileLayoutData,
+ TileGeometryMode,
+} from "@dispatch/tile-layout-contract";
+import type { TileLayoutHttp } from "./adapter/tile-layout-http";
import {
type Cell,
type GridGeometry,
@@ -13,27 +17,63 @@ import {
} from "./logic/tile-layout";
/**
- * The localStorage key for the home tile layout (spans + optional pinned
- * anchors in the universal 4-row frame — v2 documents; v1 loads migrate).
+ * Tile-layout store — the synced arrangement of the home grid, ONE shared
+ * document PER device orientation (`wide` = landscape/desktop, `tall` =
+ * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge
+ * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout
+ * feature's store semantics:
+ *
+ * - **Orientation selection** — `setMode(mode)` (the home page's effect on
+ * the resolved grid geometry) is the boot + orientation-change trigger:
+ * an orientation CHANGE resets to the local empty default and probes that
+ * orientation's document fresh. Same-mode calls are a no-op. The store
+ * probes its INITIAL mode on creation (the boot probe).
+ * - **Server-backed** — a successful probe applies the server document;
+ * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7
+ * — no patch protocol) lightly debounced; a failed PUT on a present
+ * backend rolls back to the last server document with a quiet console
+ * warning (never an error UI).
+ * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout`
+ * broadcast, ignoring the OTHER orientation (the server fans out to all
+ * clients; the client filters by mode).
+ * - **Quiet degrade** — any probe/write failure (backend unreachable, routes
+ * not served) keeps the local state with EPHEMERAL edits (in-memory only —
+ * the retired localStorage write path is gone); logged once, no error UI.
+ * - **Sync gating** — `sync(ids)` no-ops while the first probe for an
+ * orientation is in flight (`providerState === "unknown"`): merging the
+ * live workspace list into an unprobed empty document would PUT a wiped
+ * arrangement. The caller's effect re-runs when the state settles.
+ *
+ * No ambient state: one instance per home page, created by the page entry
+ * (or injected in tests).
*/
-export const TILE_LAYOUT_STORAGE_KEY = "dispatch.workspaces.tiles";
-/**
- * Tile-layout store — a thin reactive wrapper over the persisted tile layout
- * document, persisted through the INJECTED `LocalStore` adapter (the repo's
- * local-storage effect). All mutations go through the pure `logic/tile-layout`
- * ops and write through to persistence. No ambient state: one instance per
- * home page, created by the page entry (or injected in tests).
- */
+/** Whether the backend is serving the active orientation's document. */
+export type TileLayoutProviderState = "unknown" | "present" | "absent";
+
export interface TileLayoutStore {
- /** The persisted entries, order-sorted (raw spans + universal-frame anchors). */
+ /** The persisted entries of the ACTIVE orientation, order-sorted. */
readonly entries: readonly TileLayoutEntry[];
+ /** The active orientation (the home page's resolved grid geometry mode). */
+ readonly mode: TileGeometryMode;
+ /** The backend's serving state for the ACTIVE orientation. */
+ readonly providerState: TileLayoutProviderState;
+ /**
+ * Select the active orientation and probe its document. Same-mode calls
+ * are a no-op (the boot probe fires on creation for the initial mode);
+ * an orientation CHANGE resets to the local default + probes fresh.
+ */
+ setMode(mode: TileGeometryMode): void;
+ /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */
+ probe(): Promise<void>;
+ /** Server `tile-layout` broadcast. Other orientations are ignored. */
+ onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void;
/**
* Merge the live workspace ids into the layout: workspaces unknown to the
* layout append (unpinned — they flow to the first free cells), entries for
- * deleted workspaces drop (their cells become gaps). A no-op (no write) when
- * the merge changes nothing — so an effect that tracks both the workspace
- * list and this store settles instead of looping.
+ * deleted workspaces drop (their cells become gaps). A no-op (no write)
+ * when the merge changes nothing — so an effect that tracks both the
+ * workspace list and this store settles instead of looping.
*/
sync(workspaceIds: readonly string[]): void;
/** Resize a tile (persisted spans; render-time clamping is separate). */
@@ -53,23 +93,167 @@ export interface TileLayoutStore {
sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void;
}
-export function createTileLayoutStore(persistence: LocalStore<TileLayoutData>): TileLayoutStore {
- // Load once at creation; a corrupt/foreign document sanitizes to empty.
- let data = $state<TileLayoutData>(sanitizeLayout(persistence.load()));
+export interface TileLayoutStoreOptions {
+ /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */
+ readonly http: TileLayoutHttp;
+ /** Edit-PUT debounce window in ms (default 300; tests shrink it). */
+ readonly debounceMs?: number;
+ /** The quiet-failure hook (default `console.warn` — never an error UI). */
+ readonly warn?: (message: string) => void;
+}
+
+const DEFAULT_DEBOUNCE_MS = 300;
+
+export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore {
+ const http = opts.http;
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const warn = opts.warn ?? ((message: string) => console.warn(message));
+
+ let mode = $state<TileGeometryMode>("wide");
+ // The ACTIVE orientation's document — the server's copy once a probe has
+ // landed (sanitized defensively; the backend validates strictly), the
+ // empty default until then.
+ let data = $state<TileLayoutData>(sanitizeLayout(null));
+ let providerState = $state<TileLayoutProviderState>("unknown");
+
+ /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */
+ let lastServerLayout: TileLayoutData | null = null;
+ /** The pending edit-PUT timer (the "lightly debounced" write). */
+ let putTimer: ReturnType<typeof setTimeout> | null = null;
+ /** Logged-once guard for the degrade log (reset when the backend returns). */
+ let loggedAbsent = false;
+
+ function noteAbsent(reason: string): void {
+ providerState = "absent";
+ if (!loggedAbsent) {
+ loggedAbsent = true;
+ warn(
+ `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`,
+ );
+ }
+ }
+
+ function notePresent(): void {
+ providerState = "present";
+ loggedAbsent = false;
+ }
+
+ function applyServerLayout(next: TileLayoutData): void {
+ lastServerLayout = next;
+ data = next;
+ }
+
+ function clearPutTimer(): void {
+ if (putTimer !== null) {
+ clearTimeout(putTimer);
+ putTimer = null;
+ }
+ }
+
+ async function flushPut(): Promise<void> {
+ putTimer = null;
+ // Read the CURRENT document at fire time: a newer edit supersedes the
+ // debounced snapshot (one in-flight write per window, last wins).
+ const result = await http.putLayout(mode, { version: 2, tiles: data.tiles });
+ if (result.ok) {
+ notePresent();
+ // The server's validated echo is the new rollback target.
+ lastServerLayout = sanitizeLayout(result.layout);
+ return;
+ }
+ if (providerState !== "present") {
+ // The backend is gone (or never was): keep the local edit (ephemeral),
+ // no rollback — there is no server document to roll back TO.
+ noteAbsent(result.error);
+ return;
+ }
+ // A failed write on a PRESENT backend: roll back to the last server
+ // document, quiet console warning (never an error UI).
+ warn(
+ `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`,
+ );
+ if (lastServerLayout !== null) data = lastServerLayout;
+ }
+
+ async function runProbe(): Promise<void> {
+ // Capture the mode AT START: a setMode may happen while the fetch is in
+ // flight — only the CURRENT mode's result may land (the reset in
+ // setMode already staged the fresh default; a stale response must never
+ // overwrite it).
+ const probed = mode;
+ const result = await http.getLayout(mode);
+ if (probed !== mode) return;
+ if (result.ok) {
+ notePresent();
+ applyServerLayout(sanitizeLayout(result.layout));
+ } else {
+ // A real failure (5xx, 404 = routes not served, malformed, network)
+ // degrades quietly — the local default keeps rendering.
+ noteAbsent(result.error);
+ }
+ }
function commit(tiles: readonly TileLayoutEntry[]): void {
// A pure op that changed nothing (e.g. a no-op place) doesn't write.
if (sameEntries(tiles, data.tiles)) return;
data = { version: 2, tiles };
- persistence.save(data);
+ if (providerState === "unknown") return; // first probe still in flight
+ if (providerState === "absent") return; // ephemeral — no write, no warning
+ clearPutTimer();
+ putTimer = setTimeout(() => {
+ void flushPut().catch(() => {
+ // Network-grade failure: roll back to the last server document, quietly.
+ warn("tile-layout: persisting the layout failed — rolled back to the last server document");
+ if (lastServerLayout !== null) data = lastServerLayout;
+ });
+ }, debounceMs);
}
+ // Boot probe for the initial mode — the home page's setMode effect either
+ // confirms it (no-op) or switches it (fresh probe).
+ void runProbe();
+
return {
get entries(): readonly TileLayoutEntry[] {
return data.tiles;
},
+ get mode(): TileGeometryMode {
+ return mode;
+ },
+ get providerState(): TileLayoutProviderState {
+ return providerState;
+ },
+
+ setMode(next) {
+ if (next === mode) return;
+ mode = next;
+ // The document belongs to the OLD orientation now: reset to the local
+ // default while the probe resolves (never flash the other
+ // orientation's arrangement), and drop any queued edit.
+ clearPutTimer();
+ lastServerLayout = null;
+ data = sanitizeLayout(null);
+ providerState = "unknown";
+ void runProbe();
+ },
+
+ async probe() {
+ await runProbe();
+ },
+
+ onServerTileLayout(pushMode, next) {
+ if (pushMode !== mode) return; // clients filter by orientation
+ notePresent();
+ clearPutTimer(); // the server's copy supersedes any queued edit
+ applyServerLayout(sanitizeLayout(next));
+ },
sync(workspaceIds): void {
+ // Never merge into an UNPROBED empty document: before the first probe
+ // resolves, "empty" is our placeholder, not a real arrangement — and
+ // merging live ids into it could PUT a wiped layout. The caller's
+ // effect re-runs when the state settles (it tracks providerState).
+ if (providerState === "unknown") return;
const merged = mergeLayout(data, workspaceIds);
if (!sameEntries(merged, data.tiles)) commit(merged);
},
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts
index be6a14e..15f7a74 100644
--- a/src/features/workspaces/tile-layout-store.test.ts
+++ b/src/features/workspaces/tile-layout-store.test.ts
@@ -1,176 +1,301 @@
-import { describe, expect, it } from "vitest";
-import type { LocalStore } from "../../adapters/local-storage";
-import { geometryFor, type TileLayoutData, type TileLayoutEntry } from "./logic/tile-layout";
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http";
+import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout";
import { createTileLayoutStore } from "./tile-layout-store.svelte";
const WIDE = geometryFor(1920, 1080);
-/** An in-memory `LocalStore` (the injected outermost edge — no mocks of our own modules). */
-function memoryStore(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & {
- saved: TileLayoutData[];
+function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry {
+ return { id, order, colSpan, rowSpan };
+}
+
+function doc(...tiles: TileLayoutEntry[]): TileLayoutData {
+ return { version: 2, tiles };
+}
+
+/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */
+function fakeHttp(
+ initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {},
+): TileLayoutHttp & {
+ docs: Map<TileGeometryMode, TileLayoutData>;
+ puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>;
+ failNext: { get: string | null; put: string | null };
} {
- let value = initial;
- const saved: TileLayoutData[] = [];
+ const docs = new Map<TileGeometryMode, TileLayoutData>(
+ Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>,
+ );
+ const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = [];
+ const failNext = { get: null as string | null, put: null as string | null };
+ const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout });
return {
- saved,
- load: () => value,
- save(next: TileLayoutData) {
- saved.push(next);
- value = next;
+ docs,
+ puts,
+ failNext,
+ async getLayout(mode) {
+ if (failNext.get !== null) {
+ const error = failNext.get;
+ failNext.get = null;
+ return { ok: false, notFound: false, error };
+ }
+ return ok(docs.get(mode) ?? doc());
},
- clear() {
- value = null;
+ async putLayout(mode, layout) {
+ if (failNext.put !== null) {
+ const error = failNext.put;
+ failNext.put = null;
+ return { ok: false, notFound: false, error };
+ }
+ puts.push({ mode, layout });
+ docs.set(mode, layout);
+ return ok(layout);
+ },
+ async deleteLayout(mode) {
+ docs.set(mode, doc());
+ return ok(docs.get(mode) as TileLayoutData);
},
};
}
-function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry {
- return { id, order, colSpan, rowSpan };
+/** Flush the boot probe + debounced timers (microtasks first, then the clock). */
+async function settle(): Promise<void> {
+ await vi.advanceTimersByTimeAsync(0);
+ await vi.advanceTimersByTimeAsync(300);
+ await Promise.resolve();
+}
+
+async function flushOnly(): Promise<void> {
+ await vi.advanceTimersByTimeAsync(0);
+ await Promise.resolve();
}
describe("createTileLayoutStore", () => {
- it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] });
- const store = createTileLayoutStore(persistence);
- expect(store.entries).toEqual([tile("a", 0, 2, 2)]);
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
});
- it("loads a v2 document with anchors intact", () => {
- const persistence = memoryStore({
- version: 2,
- tiles: [{ ...tile("a", 0), col: 2, row: 3 }],
- });
- const store = createTileLayoutStore(persistence);
- expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]);
+ it("the boot probe applies the server document for the initial mode", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+ expect(store.entries).toEqual([tile("a", 0, 2, 2)]);
+ expect(store.providerState).toBe("present");
+ expect(store.mode).toBe("wide");
});
- it("sanitizes a corrupt persisted document to empty", () => {
- const persistence = memoryStore(null);
- // The adapter's load is untyped at the boundary — simulate garbage.
- const store = createTileLayoutStore({
- load: () => ({ junk: true }) as unknown as TileLayoutData,
- save: () => {},
- clear: () => {},
- });
+ it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => {
+ const http = fakeHttp();
+ http.docs.set("wide", { junk: true } as unknown as TileLayoutData);
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
expect(store.entries).toEqual([]);
- expect(persistence.saved).toEqual([]);
});
- it("sync appends unknown workspaces and drops deleted ones, persisting once", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0), tile("gone", 1)] });
- const store = createTileLayoutStore(persistence);
+ it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => {
+ const warn = vi.fn();
+ const http = fakeHttp();
+ http.failNext.get = "network down";
+ const store = createTileLayoutStore({ http, warn });
+ await flushOnly();
+ expect(store.providerState).toBe("absent");
- store.sync(["a", "b"]);
+ store.resize("a", 3, 3); // applied locally…
+ store.sync(["a"]);
+ await settle();
+ expect(store.entries.map((t) => t.id)).toEqual(["a"]);
+ expect(http.puts).toHaveLength(0); // …but never written
+ expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI
+ });
- expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]);
- expect(persistence.saved).toHaveLength(1);
- // The max order counts KEPT entries (the dropped one doesn't reserve a slot).
- expect(persistence.saved[0]).toEqual({
- version: 2,
- tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }],
+ it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => {
+ const http = fakeHttp({
+ wide: doc(tile("desktop", 0)),
+ tall: doc(tile("mobile", 0)),
});
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
+
+ store.setMode("tall");
+ await flushOnly();
+ expect(store.mode).toBe("tall");
+ expect(store.entries.map((t) => t.id)).toEqual(["mobile"]);
+
+ store.setMode("tall"); // same-mode call: a no-op
+ store.setMode("wide");
+ await flushOnly();
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
});
- it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => {
- const persistence = memoryStore({
- version: 2,
- tiles: [
- { ...tile("a", 0), col: 1, row: 1 },
- { ...tile("gone", 1), col: 1, row: 2 },
- ],
- });
- const store = createTileLayoutStore(persistence);
+ it("an in-flight probe for a stale mode never lands", async () => {
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)) });
+ // A holder object defeats TS's closure narrowing (a bare `let` read after
+ // the callback-assigned write narrows to `null` and the call sites to
+ // `never`).
+ const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null };
+ const slow = http.getLayout.bind(http);
+ http.getLayout = (mode) =>
+ mode === "tall"
+ ? new Promise<TileLayoutHttpResult>((resolve) => {
+ gate.release = resolve;
+ })
+ : slow(mode);
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.setMode("tall"); // probe in flight…
+ store.setMode("wide"); // …superseded before it resolves
+ gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) });
+ await flushOnly();
+
+ // The stale tall response must NOT overwrite the active wide document.
+ expect(store.mode).toBe("wide");
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
+ });
- store.sync(["a"]);
+ it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
- expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]);
+ store.onServerTileLayout("tall", doc(tile("mobile-push", 0)));
+ expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered
+
+ store.onServerTileLayout("wide", doc(tile("push", 0)));
+ expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied
});
- it("sync is a no-op (no write) when the merge changes nothing", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
- const store = createTileLayoutStore(persistence);
+ it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, debounceMs: 300 });
+ await flushOnly();
- store.sync(["a"]);
- store.sync(["a"]);
+ store.resize("a", 4, 2);
+ expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait
+ expect(http.puts).toHaveLength(0);
- expect(persistence.saved).toHaveLength(0);
+ await vi.advanceTimersByTimeAsync(300);
+ expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]);
});
- it("resize persists the new spans without clamping", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
- const store = createTileLayoutStore(persistence);
+ it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => {
+ const http = fakeHttp({ tall: doc(tile("m", 0)) });
+ const store = createTileLayoutStore({ http });
+ store.setMode("tall");
+ await flushOnly();
+ store.resize("m", 2, 2);
+ await settle();
+ expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]);
+ });
+
+ it("a failed PUT on a present backend rolls back to the last server document", async () => {
+ const warn = vi.fn();
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, warn, debounceMs: 300 });
+ await flushOnly();
+
+ http.failNext.put = "500 gone";
store.resize("a", 4, 2);
+ await settle();
- expect(store.entries).toEqual([tile("a", 0, 4, 2)]);
- expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]);
+ expect(store.entries).toEqual([tile("a", 0)]); // rolled back
+ expect(warn).toHaveBeenCalledTimes(1);
});
- it("placeAtCell commits the displacement and pins EVERY tile", () => {
- const persistence = memoryStore({
- version: 1,
- tiles: [tile("a", 0), tile("b", 1)],
- });
- const store = createTileLayoutStore(persistence);
+ it("sync merges the live workspace list (gated while the first probe is in flight)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ // NO flush — the boot probe is still in flight (providerState unknown).
+
+ store.sync(["a", "b"]); // would merge into the empty placeholder…
+ expect(store.providerState).toBe("unknown");
+ expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles
+
+ await flushOnly();
+ store.sync(["a", "b"]);
+ expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]);
+ });
+
+ it("sync appends unknown ids, drops deleted ones, and persists once", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.sync(["a", "b"]);
+ await settle();
+
+ expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]);
+ // The max order counts KEPT entries (the dropped one doesn't reserve a slot).
+ expect(http.puts.at(-1)?.layout).toEqual(
+ doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }),
+ );
+ });
+
+ it("sync is a no-op (no write) when the merge changes nothing", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.sync(["a"]);
+ store.sync(["a"]);
+ await settle();
+
+ expect(http.puts).toHaveLength(0);
+ });
+
+ it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
// a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell.
store.placeAtCell("a", { col: 2, row: 1 }, WIDE);
+ await settle();
const entries = store.entries;
expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 });
expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 });
- // Written as v2.
- expect(persistence.saved.at(-1)?.version).toBe(2);
+ expect(http.puts.at(-1)?.layout.version).toBe(2);
});
- it("placeAtCell with an unknown id is a no-op (no write)", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
- const store = createTileLayoutStore(persistence);
+ it("placeAtCell with an unknown id is a no-op (no write)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
store.placeAtCell("nope", { col: 1, row: 1 }, WIDE);
+ await settle();
- expect(persistence.saved).toHaveLength(0);
+ expect(http.puts).toHaveLength(0);
expect(store.entries).toEqual([tile("a", 0)]);
});
- it("placeFirstEmpty PINS the new id at the first empty slot", () => {
- const persistence = memoryStore({
- version: 1,
- tiles: [tile("a", 0, 1, 2), tile("b", 1)],
- });
- const store = createTileLayoutStore(persistence);
+ it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
store.placeFirstEmpty("new", WIDE);
+ await settle();
// a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned.
expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
- expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]);
- });
-
- it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => {
- const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] });
- const store = createTileLayoutStore(persistence);
- store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned)
-
- store.placeFirstEmpty("x", WIDE);
- const pinned = store.entries;
- store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable
+ expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]);
- expect(store.entries).toEqual(pinned);
- expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1);
- // x is PINNED at the first empty slot: a@(1,1) → (1,2).
- expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 });
+ store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable
+ expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
});
- it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => {
- const persistence = memoryStore({
- version: 2,
- tiles: [
- { ...tile("a", 0), col: 1, row: 1 },
- { ...tile("b", 1), col: 2, row: 3 },
- ],
+ it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => {
+ const http = fakeHttp({
+ wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }),
});
- const store = createTileLayoutStore(persistence);
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
store.sortByRecentActivity(
new Map([
@@ -182,4 +307,27 @@ describe("createTileLayoutStore", () => {
expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]);
expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true);
});
+
+ it("a WS push supersedes a queued edit (the server's copy wins)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, debounceMs: 300 });
+ await flushOnly();
+
+ store.resize("a", 4, 2); // edit queued…
+ store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first
+ await settle();
+
+ expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]);
+ expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT
+ });
+
+ it("probe() re-reads the active orientation (the reconnect catch-up)", async () => {
+ const http = fakeHttp({ wide: doc() });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ http.docs.set("wide", doc(tile("fresh", 0)));
+ await store.probe();
+ expect(store.entries.map((t) => t.id)).toEqual(["fresh"]);
+ });
});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 7c0db85..798d1bb 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -7,7 +7,6 @@
type PluginsStore,
type WorkspaceLayoutPort,
} from "../../plugins";
- import { createLocalStore } from "../../../adapters/local-storage";
import type { WorkspaceStore } from "../store.svelte";
import { workspacePath } from "../logic/route";
import {
@@ -17,11 +16,7 @@
type GridGeometry,
type Span,
} from "../logic/tile-layout";
- import {
- createTileLayoutStore,
- TILE_LAYOUT_STORAGE_KEY,
- type TileLayoutStore,
- } from "../tile-layout-store.svelte";
+ import { type TileLayoutStore } from "../tile-layout-store.svelte";
import HomeHeader from "./HomeHeader.svelte";
import HomeTileGrid from "./HomeTileGrid.svelte";
import TileActionBar from "./TileActionBar.svelte";
@@ -59,33 +54,30 @@
*/
layout?: WorkspaceLayoutPort;
/**
- * The persisted tile layout store. Absent → created here over the real
- * localStorage adapter (the page is the effect edge); tests inject one.
+ * 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;
+ tiles: TileLayoutStore;
} = $props();
onMount(() => {
void store.refresh();
});
- // ── 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,
- }),
- );
+ // 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
- // 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").
+ // (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;
@@ -111,6 +103,15 @@
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.
const placements = $derived(pack(tiles.entries, geometry));
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
index eb5b293..9e8411a 100644
--- a/src/features/workspaces/ui/WorkspacesHome.test.ts
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -1,10 +1,10 @@
+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 { LocalStore } from "../../../adapters/local-storage";
import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http";
-import type { TileLayoutData, TileLayoutEntry } from "../logic/tile-layout";
+import type { TileLayoutHttp } from "../adapter/tile-layout-http";
import { createTileLayoutStore } from "../tile-layout-store.svelte";
import WorkspacesHome from "./WorkspacesHome.svelte";
@@ -46,27 +46,37 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
} as unknown as WorkspaceHttp;
}
-function memoryLayout(initial: TileLayoutData | null = null): LocalStore<TileLayoutData> & {
- data: TileLayoutData | null;
-} {
- const store = {
- data: initial,
- load: () => store.data,
- save: (value: TileLayoutData) => {
- store.data = value;
+/**
+ * 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 };
},
- clear: () => {
- store.data = null;
+ async deleteLayout(mode) {
+ docs.set(mode, { version: 2, tiles: [] });
+ return { ok: true, layout: docs.get(mode) as TileLayoutData };
},
};
- return store;
}
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: 1,
+ version: 2,
tiles: [
{ id: "b", order: 0, colSpan: 1, rowSpan: 1 },
{ id: "a", order: 1, colSpan: 1, rowSpan: 1 },
@@ -77,7 +87,7 @@ describe("WorkspacesHome (tile canvas)", () => {
store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
onNavigate: vi.fn(),
computers: [],
- tiles: createTileLayoutStore(layout),
+ tiles: createTileLayoutStore({ http: layout }),
},
});
@@ -94,7 +104,7 @@ describe("WorkspacesHome (tile canvas)", () => {
store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
onNavigate,
computers: [],
- tiles: createTileLayoutStore(memoryLayout()),
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
},
});
@@ -113,7 +123,7 @@ describe("WorkspacesHome (tile canvas)", () => {
store,
onNavigate,
computers: [],
- tiles: createTileLayoutStore(memoryLayout()),
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
},
});
@@ -139,7 +149,7 @@ describe("WorkspacesHome (tile canvas)", () => {
store,
onNavigate: vi.fn(),
computers: [],
- tiles: createTileLayoutStore(memoryLayout()),
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
},
});
@@ -159,7 +169,7 @@ describe("WorkspacesHome (tile canvas)", () => {
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: 1,
+ version: 2,
tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }],
});
render(WorkspacesHome, {
@@ -167,7 +177,7 @@ describe("WorkspacesHome (tile canvas)", () => {
store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
onNavigate: vi.fn(),
computers: [],
- tiles: createTileLayoutStore(layout),
+ tiles: createTileLayoutStore({ http: layout }),
},
});
@@ -176,7 +186,9 @@ describe("WorkspacesHome (tile canvas)", () => {
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.
- const entries = (layout.data?.tiles ?? []).map((tile: TileLayoutEntry) => tile.id);
- expect(entries).toEqual(["b", "a"]);
+ await waitFor(() => {
+ const tiles = layout.docs.get("wide")?.tiles ?? [];
+ expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]);
+ });
});
});