summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/workspaces
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/workspaces')
-rw-r--r--src/features/workspaces/adapter/http.test.ts30
-rw-r--r--src/features/workspaces/adapter/http.ts24
-rw-r--r--src/features/workspaces/index.ts6
-rw-r--r--src/features/workspaces/logic/create.test.ts50
-rw-r--r--src/features/workspaces/logic/create.ts64
-rw-r--r--src/features/workspaces/store.svelte.ts15
-rw-r--r--src/features/workspaces/store.test.ts61
-rw-r--r--src/features/workspaces/tile-layout-store.svelte.ts34
-rw-r--r--src/features/workspaces/tile-layout-store.test.ts116
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte23
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts144
11 files changed, 535 insertions, 32 deletions
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
index 2e34822..d6409ca 100644
--- a/src/features/workspaces/adapter/http.test.ts
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => {
expect(result).toEqual({ ok: false, error: "invalid slug" });
});
- it("get returns null on 404", async () => {
+ it("get reports a missing lookup on 404", async () => {
const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
- expect(await http.get("nope")).toBeNull();
+ expect(await http.get("nope")).toEqual({ kind: "missing" });
});
it("get returns the workspace on 200", async () => {
- const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const ws = {
+ id: "x",
+ title: "X",
+ defaultCwd: "/home",
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
- expect(await http.get("x")).toEqual(ws);
+ expect(await http.get("x")).toEqual({ kind: "found", workspace: ws });
+ });
+
+ it("get distinguishes a failed response from 404 (not 'missing')", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 500, body: { error: "db down" } }]),
+ );
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" });
+ });
+
+ it("get reports a network error as failed (not 'missing')", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "network" });
});
it("setTitle PUTs the title", async () => {
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index de95a61..3f7f058 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -9,6 +9,7 @@ import type {
WorkspaceListResponse,
WorkspaceResponse,
} from "@dispatch/transport-contract";
+import type { WorkspaceLookup } from "../logic/create";
/**
* Workspace HTTP effects — the injected edge that talks to the backend's
@@ -21,7 +22,7 @@ import type {
* Endpoints ([email protected]):
* - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list
* - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure
- * - `GET /workspaces/:id` (404 → null) → get
+ * - `GET /workspaces/:id` (found | 404-missing | failed) → get
* - `PUT /workspaces/:id/title` → rename
* - `PUT /workspaces/:id/default-cwd` → set/clear default cwd
* - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2)
@@ -41,7 +42,12 @@ export type WorkspaceArchivedFilter = "true" | "false" | "all";
export interface WorkspaceHttp {
list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>;
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
+ /**
+ * Authoritative existence probe. 404 → `missing`, a network/server failure →
+ * `failed` (distinct on purpose: the create gate must not treat a broken
+ * check as "doesn't exist"), 200 → the workspace (`found`).
+ */
+ get(id: string): Promise<WorkspaceLookup>;
setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
@@ -96,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch):
}
},
- async get(id): Promise<Workspace | null> {
+ async get(id): Promise<WorkspaceLookup> {
try {
const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`);
- if (res.status === 404 || !res.ok) return null;
- return (await res.json()) as WorkspaceResponse;
- } catch {
- return null;
+ if (res.status === 404) return { kind: "missing" };
+ if (!res.ok) return { kind: "failed", error: await errText(res) };
+ return { kind: "found", workspace: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ kind: "failed",
+ error: err instanceof Error ? err.message : "Workspace lookup failed",
+ };
}
},
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 1a60bb7..c7f57d5 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -1,6 +1,12 @@
export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http";
+export type { CreateGate, WorkspaceLookup } from "./logic/create";
+export {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+} from "./logic/create";
export type {
DragEmit,
DragEvent as DragControllerEvent,
diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts
new file mode 100644
index 0000000..278bcd6
--- /dev/null
+++ b/src/features/workspaces/logic/create.test.ts
@@ -0,0 +1,50 @@
+import type { Workspace } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+ type WorkspaceLookup,
+} from "./create";
+
+function workspace(overrides: Partial<Workspace> = {}): Workspace {
+ return {
+ id: "old",
+ title: "Old",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ ...overrides,
+ };
+}
+
+describe("gateCreate", () => {
+ it("blocks a slug whose workspace is archived, with the can't-create message", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) };
+ const gate = gateCreate("old", lookup);
+ expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'A workspace named "old" already exists and is archived — you can\'t create it.',
+ );
+ });
+
+ it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) };
+ expect(gateCreate("old", lookup)).toEqual({ ok: true });
+ });
+
+ it("proceeds for a missing slug", () => {
+ expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true });
+ });
+
+ it("blocks when the existence probe failed (never guesses)", () => {
+ const gate = gateCreate("maybe", { kind: "failed", error: "db down" });
+ expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'Couldn\'t check whether a workspace named "maybe"',
+ );
+ });
+});
diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts
new file mode 100644
index 0000000..c9385e1
--- /dev/null
+++ b/src/features/workspaces/logic/create.ts
@@ -0,0 +1,64 @@
+/**
+ * Pure create-gate logic for the New-workspace form — zero DOM, zero effects.
+ *
+ * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs
+ * to an ARCHIVED workspace would silently "succeed": the backend returns the
+ * archived workspace, the active list (which excludes archived) never shows it,
+ * and the home grid would still place a tile for something invisible. The
+ * frontend therefore probes `GET /workspaces/:id` first and refuses the create
+ * when the slug exists and is archived.
+ */
+
+import type { Workspace } from "@dispatch/wire";
+
+/**
+ * The authoritative existence probe's outcome (`GET /workspaces/:id`). The
+ * adapter maps HTTP status to these kinds; `failed` is deliberately distinct
+ * from `missing` (404) so a broken check is never mistaken for "no such
+ * workspace".
+ */
+export type WorkspaceLookup =
+ | { readonly kind: "found"; readonly workspace: Workspace }
+ | { readonly kind: "missing" }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** Whether a create may proceed: `ok` or a user-facing error message. */
+export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+/**
+ * The user-facing message when a slug belongs to an archived workspace. It
+ * states plainly that the create can't happen and points at the existing
+ * recovery affordance (the Archived workspaces list) without adding UI.
+ */
+export function archivedCreateMessage(id: string): string {
+ return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`;
+}
+
+/**
+ * The user-facing message when the existence probe itself failed. Proceeding
+ * blindly could recreate the silent archived-tile bug, so the create is
+ * refused with a retryable message instead.
+ */
+export function checkFailedCreateMessage(id: string, error: string): string {
+ return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`;
+}
+
+/**
+ * Decide whether a create may proceed, given the probe outcome. Pure:
+ * `(id, lookup) → gate`.
+ *
+ * - archived slug → blocked with the "can't create" message;
+ * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent,
+ * so an active slug keeps today's behavior);
+ * - a failed probe → blocked too (plan §3.4): never guess, because proceeding
+ * could ensure an archived workspace's tile.
+ */
+export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate {
+ if (lookup.kind === "failed") {
+ return { ok: false, error: checkFailedCreateMessage(id, lookup.error) };
+ }
+ if (lookup.kind === "found" && lookup.workspace.archived) {
+ return { ok: false, error: archivedCreateMessage(id) };
+ }
+ return { ok: true };
+}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 57e34b2..b9b646b 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -1,6 +1,7 @@
import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract";
import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
+import { gateCreate } from "./logic/create";
import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model";
/**
@@ -27,6 +28,14 @@ export interface WorkspaceStore {
refreshArchived(): Promise<void>;
/** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /**
+ * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id`
+ * first and refuses a slug that belongs to an ARCHIVED workspace (the
+ * idempotent `ensure` would otherwise silently resurrect an invisible tile).
+ * A failed probe is refused too (see `gateCreate`). On success it falls
+ * through to `ensure`, unchanged.
+ */
+ create(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
/** Rename a workspace (display only; id unchanged). */
rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
/** Set/clear a workspace's default cwd. */
@@ -102,6 +111,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
}
},
+ async create(id, body): Promise<WorkspaceResult<Workspace>> {
+ const gate = gateCreate(id, await http.get(id));
+ if (!gate.ok) return { ok: false, error: gate.error };
+ return this.ensure(id, body);
+ },
+
async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
const result = await http.ensure(id, body);
if (result.ok) void this.refresh();
diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts
index a44d8a6..faec5bf 100644
--- a/src/features/workspaces/store.test.ts
+++ b/src/features/workspaces/store.test.ts
@@ -1,6 +1,7 @@
import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceResult } from "./adapter/http";
+import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create";
import { createWorkspaceStore } from "./store.svelte";
function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
@@ -25,14 +26,23 @@ function fakeHttp(opts: {
unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>;
archive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ get?: (id: string) => Promise<WorkspaceLookup>;
+ ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>;
}) {
return {
list: vi.fn(
async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> =>
opts.list ?? [],
),
- ensure: vi.fn(),
- get: vi.fn(),
+ ensure:
+ opts.ensure ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id }),
+ }),
+ ),
+ get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))),
setTitle: vi.fn(),
setDefaultCwd: vi.fn(),
setDefaultComputer: vi.fn(),
@@ -224,6 +234,53 @@ describe("createWorkspaceStore — setArchived", () => {
});
});
+describe("createWorkspaceStore — create (archived-slug gate)", () => {
+ it("refuses an archived slug with the can't-create message and never calls ensure", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("old");
+
+ expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+
+ it("proceeds for an existing non-archived slug (ensure is called as before)", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("taken");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("proceeds for a brand-new slug (404 → missing)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "missing" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("fresh");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("maybe");
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.error).toContain("Couldn't check whether");
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+});
+
describe("createWorkspaceStore — refreshArchived", () => {
it("loads the archived list via list('true') without touching the main list", async () => {
const http = fakeHttp({ list: [entry({ id: "active", archived: false })] });
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts
index a910c73..98a5c33 100644
--- a/src/features/workspaces/tile-layout-store.svelte.ts
+++ b/src/features/workspaces/tile-layout-store.svelte.ts
@@ -2,6 +2,7 @@ import type {
TileLayoutData as ContractTileLayoutData,
TileGeometryMode,
} from "@dispatch/tile-layout-contract";
+import { createLocalStore } from "../../adapters/local-storage";
import type { TileLayoutHttp } from "./adapter/tile-layout-http";
import {
type Cell,
@@ -25,7 +26,7 @@ import {
*
* - **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
+ * an orientation CHANGE seeds from its cached server layout 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;
@@ -38,7 +39,7 @@ import {
* 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.
+ * only server-confirmed layouts are cached); 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
@@ -61,7 +62,7 @@ export interface TileLayoutStore {
/**
* 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.
+ * an orientation CHANGE seeds from its cache + probes fresh.
*/
setMode(mode: TileGeometryMode): void;
/** Re-probe the ACTIVE orientation's document (reconnect catch-up). */
@@ -96,6 +97,10 @@ export interface TileLayoutStore {
export interface TileLayoutStoreOptions {
/** The injected HTTP edge (the `home-tile-layout` plugin's routes). */
readonly http: TileLayoutHttp;
+ /** Initial viewport orientation (default wide). */
+ readonly initialMode?: TileGeometryMode;
+ /** Injected localStorage for server-layout snapshots; absent → no cache. */
+ readonly storage?: Storage | undefined;
/** 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). */
@@ -109,11 +114,16 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
const warn = opts.warn ?? ((message: string) => console.warn(message));
- let mode = $state<TileGeometryMode>("wide");
+ const cache = {
+ wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }),
+ tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }),
+ };
+ const initialMode = opts.initialMode ?? "wide";
+ let mode = $state<TileGeometryMode>(initialMode);
// 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));
+ // cached server snapshot (or empty default) until then. Never PUT the seed.
+ let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load()));
let providerState = $state<TileLayoutProviderState>("unknown");
/** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */
@@ -141,6 +151,7 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
function applyServerLayout(next: TileLayoutData): void {
lastServerLayout = next;
data = next;
+ cache[mode].save(next);
}
function clearPutTimer(): void {
@@ -154,7 +165,10 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
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 });
+ const writtenMode = mode;
+ const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles });
+ if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout));
+ if (writtenMode !== mode) return;
if (result.ok) {
notePresent();
// The server's validated echo is the new rollback target.
@@ -227,12 +241,12 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
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
+ // The document belongs to the OLD orientation now: seed from this
+ // mode's cache while the probe resolves (never flash the other
// orientation's arrangement), and drop any queued edit.
clearPutTimer();
lastServerLayout = null;
- data = sanitizeLayout(null);
+ data = sanitizeLayout(cache[mode].load());
providerState = "unknown";
void runProbe();
},
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts
index 15f7a74..b5af8b6 100644
--- a/src/features/workspaces/tile-layout-store.test.ts
+++ b/src/features/workspaces/tile-layout-store.test.ts
@@ -72,9 +72,11 @@ async function flushOnly(): Promise<void> {
describe("createTileLayoutStore", () => {
beforeEach(() => {
vi.useFakeTimers();
+ localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
+ localStorage.clear();
});
it("the boot probe applies the server document for the initial mode", async () => {
@@ -86,6 +88,118 @@ describe("createTileLayoutStore", () => {
expect(store.mode).toBe("wide");
});
+ it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => {
+ const http = fakeHttp({ tall: doc(tile("mobile", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, initialMode: "tall" });
+ store.setMode("tall");
+ await flushOnly();
+ expect(get.mock.calls).toEqual([["tall"]]);
+ expect(store.mode).toBe("tall");
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ });
+
+ it("seeds from cache immediately without PUT or caching edits before revalidation", async () => {
+ const cached = doc(tile("cached", 0, 2, 2));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.getLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage });
+ expect(store.entries).toEqual(cached.tiles);
+ expect(store.providerState).toBe("unknown");
+
+ store.sync(["cached", "new"]);
+ store.resize("cached", 1, 1);
+ await settle();
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached);
+
+ const fresh = doc(tile("server", 0));
+ gate.release?.({ ok: true, layout: fresh });
+ await settle();
+ expect(store.entries).toEqual(fresh.tiles);
+ expect(store.providerState).toBe("present");
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh);
+ });
+
+ it("resets to the new mode's cache and re-probes on orientation change", async () => {
+ const cached = doc(tile("cached-mobile", 0));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+
+ store.setMode("tall");
+ expect(store.providerState).toBe("unknown");
+ expect(store.entries).toEqual(cached.tiles);
+ await flushOnly();
+ expect(store.entries).toEqual([]);
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+
+ it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null");
+ await flushOnly();
+ expect(cached()).toEqual(doc(tile("a", 0)));
+
+ store.onServerTileLayout("tall", doc(tile("ignored", 0)));
+ expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull();
+ store.onServerTileLayout("wide", doc(tile("push", 0)));
+ expect(cached()).toEqual(doc(tile("push", 0)));
+
+ store.resize("push", 2, 2);
+ expect(cached()).toEqual(doc(tile("push", 0)));
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+
+ http.failNext.put = "network down";
+ store.resize("push", 1, 1);
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+ });
+
+ it("caches an in-flight PUT echo under its written mode after an orientation change", async () => {
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) });
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.putLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+ store.resize("desktop", 2, 2);
+ await settle();
+ store.setMode("tall");
+ await flushOnly();
+ gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) });
+ await flushOnly();
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(
+ doc(tile("mobile", 0)),
+ );
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual(
+ doc(tile("desktop", 0, 2, 2)),
+ );
+ });
+
+ it.each([
+ "invalid JSON",
+ '{"version":2,"tiles":"bad"}',
+ ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => {
+ localStorage.setItem("dispatch.tile-layout.wide", raw);
+ const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage });
+ expect(store.entries).toEqual([]);
+ expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]);
+ });
+
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);
@@ -120,6 +234,8 @@ describe("createTileLayoutStore", () => {
expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
store.setMode("tall");
+ expect(store.entries).toEqual([]);
+ expect(store.providerState).toBe("unknown");
await flushOnly();
expect(store.mode).toBe("tall");
expect(store.entries.map((t) => t.id)).toEqual(["mobile"]);
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 630c67f..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 ─────────────────────────────────────────────────
@@ -215,12 +225,13 @@
if (result.ok) deselect();
}
- // ── Create (header popover): create on the backend — do NOT navigate —
- // place the new tile in the first empty slot, and go straight into rename.
+ // ── Create (header popover): check the slug resolves to no archived
+ // workspace, 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);
+ const result = await store.create(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
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
index 6316930..5a66a42 100644
--- a/src/features/workspaces/ui/WorkspacesHome.test.ts
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -4,7 +4,8 @@ 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";
@@ -24,7 +25,10 @@ function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
}
/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */
-function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
+function fakeHttp(
+ list: WorkspaceEntry[] = [ws()],
+ lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }),
+): WorkspaceHttp {
return {
list: vi.fn(async (): Promise<WorkspaceEntry[]> => list),
// Create-on-miss: the backend persists the new workspace, so the next
@@ -33,7 +37,7 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id }));
return { ok: true, value: ws({ id, title: id }) };
}),
- get: vi.fn(),
+ get: vi.fn(lookup),
setTitle: vi.fn(),
setDefaultCwd: vi.fn(),
setDefaultComputer: vi.fn(),
@@ -97,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();
@@ -140,6 +248,36 @@ describe("WorkspacesHome (tile canvas)", () => {
expect(store.list.map((w) => w.id)).toContain("fresh-ws");
});
+ it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const http = fakeHttp(list, async (id) => ({
+ kind: "found",
+ workspace: ws({ id, title: id, archived: true }),
+ }));
+ const store = (await import("../store.svelte")).createWorkspaceStore(http);
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}");
+
+ expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument();
+ // The popover stays open (the form/input is still mounted).
+ expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument();
+ // Nothing was ensured, no tile appeared, and no rename mode was entered.
+ expect(http.ensure).not.toHaveBeenCalled();
+ expect(store.list.map((w) => w.id)).toEqual(["a"]);
+ expect(screen.queryByLabelText("Workspace title")).toBeNull();
+ });
+
it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);