summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/workspaces
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/workspaces')
-rw-r--r--src/features/workspaces/adapter/http.test.ts224
-rw-r--r--src/features/workspaces/adapter/http.ts242
-rw-r--r--src/features/workspaces/index.ts14
-rw-r--r--src/features/workspaces/logic/route.test.ts112
-rw-r--r--src/features/workspaces/logic/route.ts26
-rw-r--r--src/features/workspaces/logic/view-model.test.ts98
-rw-r--r--src/features/workspaces/logic/view-model.ts32
-rw-r--r--src/features/workspaces/store.svelte.ts132
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte407
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts234
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte165
11 files changed, 842 insertions, 844 deletions
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
index adaff00..19e53f8 100644
--- a/src/features/workspaces/adapter/http.test.ts
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -3,131 +3,131 @@ import { createWorkspaceHttp } from "./http";
/** Build a fake `fetch` returning a canned Response. */
function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch {
- let i = 0;
- return vi.fn(async () => {
- const next = responses[i++];
- if (next === undefined) throw new Error("fakeFetch: no more canned responses");
- if (next instanceof Error) throw next;
- const status = next.status ?? 200;
- const body = next.body;
- return {
- ok: status >= 200 && status < 300,
- status,
- async json() {
- return body;
- },
- } as Response;
- }) as unknown as typeof fetch;
+ let i = 0;
+ return vi.fn(async () => {
+ const next = responses[i++];
+ if (next === undefined) throw new Error("fakeFetch: no more canned responses");
+ if (next instanceof Error) throw next;
+ const status = next.status ?? 200;
+ const body = next.body;
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ async json() {
+ return body;
+ },
+ } as Response;
+ }) as unknown as typeof fetch;
}
const BASE = "http://x";
describe("createWorkspaceHttp", () => {
- it("list returns the workspaces", async () => {
- const fetchImpl = fakeFetch([
- {
- body: {
- workspaces: [
- {
- id: "a",
- title: "A",
- defaultCwd: null,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- },
- ],
- },
- },
- ]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const list = await http.list();
- expect(list).toHaveLength(1);
- expect(list[0]?.id).toBe("a");
- expect(list[0]?.conversationCount).toBe(3);
- expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
- });
+ it("list returns the workspaces", async () => {
+ const fetchImpl = fakeFetch([
+ {
+ body: {
+ workspaces: [
+ {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ },
+ ],
+ },
+ },
+ ]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const list = await http.list();
+ expect(list).toHaveLength(1);
+ expect(list[0]?.id).toBe("a");
+ expect(list[0]?.conversationCount).toBe(3);
+ expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
+ });
- it("list returns [] on a failed response (non-fatal)", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }]));
- expect(await http.list()).toEqual([]);
- });
+ it("list returns [] on a failed response (non-fatal)", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }]));
+ expect(await http.list()).toEqual([]);
+ });
- it("list returns [] on a network error", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
- expect(await http.list()).toEqual([]);
- });
+ it("list returns [] on a network error", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.list()).toEqual([]);
+ });
- it("ensure PUTs the id + returns the workspace", async () => {
- const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.ensure("my-ws");
- expect(result).toEqual({ ok: true, value: ws });
- expect(fetchImpl).toHaveBeenCalledWith(
- `${BASE}/workspaces/my-ws`,
- expect.objectContaining({ method: "PUT" }),
- );
- });
+ it("ensure PUTs the id + returns the workspace", async () => {
+ const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.ensure("my-ws");
+ expect(result).toEqual({ ok: true, value: ws });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/my-ws`,
+ expect.objectContaining({ method: "PUT" }),
+ );
+ });
- it("ensure surfaces the backend error on a 400 (invalid slug)", async () => {
- const http = createWorkspaceHttp(
- BASE,
- fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
- );
- const result = await http.ensure("UPPER");
- expect(result).toEqual({ ok: false, error: "invalid slug" });
- });
+ it("ensure surfaces the backend error on a 400 (invalid slug)", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
+ );
+ const result = await http.ensure("UPPER");
+ expect(result).toEqual({ ok: false, error: "invalid slug" });
+ });
- it("get returns null on 404", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
- expect(await http.get("nope")).toBeNull();
- });
+ it("get returns null on 404", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
+ expect(await http.get("nope")).toBeNull();
+ });
- it("get returns the workspace on 200", async () => {
- const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
- const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
- expect(await http.get("x")).toEqual(ws);
- });
+ it("get returns the workspace on 200", async () => {
+ const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
+ expect(await http.get("x")).toEqual(ws);
+ });
- it("setTitle PUTs the title", async () => {
- const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.setTitle("a", "Renamed");
- expect(result).toEqual({ ok: true, value: ws });
- const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
- expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`);
- expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" });
- });
+ it("setTitle PUTs the title", async () => {
+ const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.setTitle("a", "Renamed");
+ expect(result).toEqual({ ok: true, value: ws });
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" });
+ });
- it("setDefaultCwd PUTs null to clear", async () => {
- const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- await http.setDefaultCwd("a", null);
- const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
- expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`);
- expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null });
- });
+ it("setDefaultCwd PUTs null to clear", async () => {
+ const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ await http.setDefaultCwd("a", null);
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null });
+ });
- it("delete returns closedCount", async () => {
- const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.delete("a");
- expect(result).toEqual({ ok: true, value: { closedCount: 4 } });
- expect(fetchImpl).toHaveBeenCalledWith(
- `${BASE}/workspaces/a`,
- expect.objectContaining({ method: "DELETE" }),
- );
- });
+ it("delete returns closedCount", async () => {
+ const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.delete("a");
+ expect(result).toEqual({ ok: true, value: { closedCount: 4 } });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/a`,
+ expect.objectContaining({ method: "DELETE" }),
+ );
+ });
- it("delete surfaces 409 for 'default'", async () => {
- const http = createWorkspaceHttp(
- BASE,
- fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]),
- );
- const result = await http.delete("default");
- expect(result).toEqual({ ok: false, error: "cannot delete default" });
- });
+ it("delete surfaces 409 for 'default'", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]),
+ );
+ const result = await http.delete("default");
+ expect(result).toEqual({ ok: false, error: "cannot delete default" });
+ });
});
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index 6ebfee1..01fe677 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -1,13 +1,13 @@
import type {
- DeleteWorkspaceResponse,
- EnsureWorkspaceRequest,
- SetWorkspaceDefaultComputerRequest,
- SetWorkspaceDefaultCwdRequest,
- SetWorkspaceTitleRequest,
- Workspace,
- WorkspaceEntry,
- WorkspaceListResponse,
- WorkspaceResponse,
+ DeleteWorkspaceResponse,
+ EnsureWorkspaceRequest,
+ SetWorkspaceDefaultComputerRequest,
+ SetWorkspaceDefaultCwdRequest,
+ SetWorkspaceTitleRequest,
+ Workspace,
+ WorkspaceEntry,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
/**
@@ -28,130 +28,130 @@ import type {
* - `DELETE /workspaces/:id` (409 for "default") → delete
*/
export type WorkspaceResult<T> =
- | { readonly ok: true; readonly value: T }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly value: T }
+ | { readonly ok: false; readonly error: string };
export interface WorkspaceHttp {
- list(): Promise<readonly WorkspaceEntry[]>;
- ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
- 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>>;
- delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ list(): Promise<readonly WorkspaceEntry[]>;
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ get(id: string): Promise<Workspace | null>;
+ 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>>;
+ delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
async function errText(res: Response): Promise<string> {
- try {
- const body = (await res.json()) as { error?: string };
- return body.error ?? `HTTP ${res.status}`;
- } catch {
- return `HTTP ${res.status}`;
- }
+ try {
+ const body = (await res.json()) as { error?: string };
+ return body.error ?? `HTTP ${res.status}`;
+ } catch {
+ return `HTTP ${res.status}`;
+ }
}
export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp {
- return {
- async list(): Promise<readonly WorkspaceEntry[]> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces`);
- if (!res.ok) return [];
- const data = (await res.json()) as WorkspaceListResponse;
- return data.workspaces;
- } catch {
- return [];
- }
- },
+ return {
+ async list(): Promise<readonly WorkspaceEntry[]> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces`);
+ if (!res.ok) return [];
+ const data = (await res.json()) as WorkspaceListResponse;
+ return data.workspaces;
+ } catch {
+ return [];
+ }
+ },
- async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body ?? {}),
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Workspace request failed",
- };
- }
- },
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body ?? {}),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Workspace request failed",
+ };
+ }
+ },
- async get(id): Promise<Workspace | null> {
- 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;
- }
- },
+ async get(id): Promise<Workspace | null> {
+ 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;
+ }
+ },
- async setTitle(id, title): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest),
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Rename failed" };
- }
- },
+ async setTitle(id, title): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Rename failed" };
+ }
+ },
- async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest),
- },
- );
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" };
- }
- },
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" };
+ }
+ },
- async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest),
- },
- );
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set default computer failed",
- };
- }
- },
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set default computer failed",
+ };
+ }
+ },
- async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
- method: "DELETE",
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- const data = (await res.json()) as DeleteWorkspaceResponse;
- return { ok: true, value: { closedCount: data.closedCount } };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Delete failed" };
- }
- },
- };
+ async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ const data = (await res.json()) as DeleteWorkspaceResponse;
+ return { ok: true, value: { closedCount: data.closedCount } };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Delete failed" };
+ }
+ },
+ };
}
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 6cc86a3..177e49b 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -2,11 +2,11 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
export type { Route } from "./logic/route";
export {
- DEFAULT_WORKSPACE_ID,
- isValidSlug,
- parsePath,
- WORKSPACE_SLUG_RE,
- workspacePath,
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
} from "./logic/route";
export { pageTitle, relativeTime } from "./logic/view-model";
export type { WorkspaceStore } from "./store.svelte";
@@ -16,6 +16,6 @@ export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "workspaces",
- description: "URL-driven conversation grouping with a backend-owned default cwd",
+ name: "workspaces",
+ description: "URL-driven conversation grouping with a backend-owned default cwd",
} as const;
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
index a6da3e1..96e0ff3 100644
--- a/src/features/workspaces/logic/route.test.ts
+++ b/src/features/workspaces/logic/route.test.ts
@@ -1,77 +1,77 @@
import { describe, expect, it } from "vitest";
import {
- DEFAULT_WORKSPACE_ID,
- isValidSlug,
- parsePath,
- WORKSPACE_SLUG_RE,
- workspacePath,
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
} from "./route";
describe("parsePath", () => {
- it("treats the root path as home", () => {
- expect(parsePath("/")).toEqual({ kind: "home" });
- expect(parsePath("")).toEqual({ kind: "home" });
- });
+ it("treats the root path as home", () => {
+ expect(parsePath("/")).toEqual({ kind: "home" });
+ expect(parsePath("")).toEqual({ kind: "home" });
+ });
- it("trims surrounding slashes", () => {
- expect(parsePath("//")).toEqual({ kind: "home" });
- expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
- });
+ it("trims surrounding slashes", () => {
+ expect(parsePath("//")).toEqual({ kind: "home" });
+ expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
+ });
- it("parses a single segment as a workspace id", () => {
- expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
- expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
- expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
- });
+ it("parses a single segment as a workspace id", () => {
+ expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
+ expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
+ expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
+ });
- it("takes only the first segment of a deeper path", () => {
- expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
- expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
- });
+ it("takes only the first segment of a deeper path", () => {
+ expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
+ expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
+ });
- it("URL-decodes the segment", () => {
- expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
- });
+ it("URL-decodes the segment", () => {
+ expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
+ });
- it("does not validate the slug — an invalid id is still a workspace route", () => {
- expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
- expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
- });
+ it("does not validate the slug — an invalid id is still a workspace route", () => {
+ expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
+ expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
+ });
});
describe("isValidSlug", () => {
- it("accepts lowercase alphanumeric + internal hyphens", () => {
- expect(isValidSlug("default")).toBe(true);
- expect(isValidSlug("my-workspace")).toBe(true);
- expect(isValidSlug("a")).toBe(true);
- expect(isValidSlug("ws-1")).toBe(true);
- });
+ it("accepts lowercase alphanumeric + internal hyphens", () => {
+ expect(isValidSlug("default")).toBe(true);
+ expect(isValidSlug("my-workspace")).toBe(true);
+ expect(isValidSlug("a")).toBe(true);
+ expect(isValidSlug("ws-1")).toBe(true);
+ });
- it("accepts up to 40 chars", () => {
- expect(isValidSlug("a".repeat(40))).toBe(true);
- });
+ it("accepts up to 40 chars", () => {
+ expect(isValidSlug("a".repeat(40))).toBe(true);
+ });
- it("rejects empty and too-long", () => {
- expect(isValidSlug("")).toBe(false);
- expect(isValidSlug("a".repeat(41))).toBe(false);
- });
+ it("rejects empty and too-long", () => {
+ expect(isValidSlug("")).toBe(false);
+ expect(isValidSlug("a".repeat(41))).toBe(false);
+ });
- it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
- expect(isValidSlug("MyWS")).toBe(false);
- expect(isValidSlug("has space")).toBe(false);
- expect(isValidSlug("-leading")).toBe(false);
- expect(isValidSlug("trailing-")).toBe(false);
- expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
- });
+ it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
+ expect(isValidSlug("MyWS")).toBe(false);
+ expect(isValidSlug("has space")).toBe(false);
+ expect(isValidSlug("-leading")).toBe(false);
+ expect(isValidSlug("trailing-")).toBe(false);
+ expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
+ });
- it("WORKSPACE_SLUG_RE matches the default id", () => {
- expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
- });
+ it("WORKSPACE_SLUG_RE matches the default id", () => {
+ expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
+ });
});
describe("workspacePath", () => {
- it("builds the URL path for a workspace id", () => {
- expect(workspacePath("default")).toBe("/default");
- expect(workspacePath("my-ws")).toBe("/my-ws");
- });
+ it("builds the URL path for a workspace id", () => {
+ expect(workspacePath("default")).toBe("/default");
+ expect(workspacePath("my-ws")).toBe("/my-ws");
+ });
});
diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts
index e30dc16..015c6d6 100644
--- a/src/features/workspaces/logic/route.ts
+++ b/src/features/workspaces/logic/route.ts
@@ -30,12 +30,12 @@ export const DEFAULT_WORKSPACE_ID = "default";
* surfaces the error).
*/
export function parsePath(pathname: string): Route {
- const trimmed = pathname.replace(/^\/+|\/+$/g, "");
- if (trimmed === "") return { kind: "home" };
- const first = trimmed.split("/")[0] ?? "";
- const id = safeDecode(first);
- if (id === "") return { kind: "home" };
- return { kind: "workspace", id };
+ const trimmed = pathname.replace(/^\/+|\/+$/g, "");
+ if (trimmed === "") return { kind: "home" };
+ const first = trimmed.split("/")[0] ?? "";
+ const id = safeDecode(first);
+ if (id === "") return { kind: "home" };
+ return { kind: "workspace", id };
}
/**
@@ -43,7 +43,7 @@ export function parsePath(pathname: string): Route {
* Used by the home view's "new workspace" input before navigating.
*/
export function isValidSlug(slug: string): boolean {
- return WORKSPACE_SLUG_RE.test(slug);
+ return WORKSPACE_SLUG_RE.test(slug);
}
/**
@@ -51,13 +51,13 @@ export function isValidSlug(slug: string): boolean {
* workspace. Pure: id in, path string out.
*/
export function workspacePath(id: string): string {
- return `/${id}`;
+ return `/${id}`;
}
function safeDecode(segment: string): string {
- try {
- return decodeURIComponent(segment);
- } catch {
- return segment;
- }
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
}
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
index a4ed19c..86342e7 100644
--- a/src/features/workspaces/logic/view-model.test.ts
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -3,66 +3,66 @@ import { describe, expect, it } from "vitest";
import { pageTitle, relativeTime } from "./view-model";
describe("relativeTime", () => {
- const now = 1_000_000_000_000; // 2001-09-09
+ const now = 1_000_000_000_000; // 2001-09-09
- it("is 'now' within a minute", () => {
- expect(relativeTime(now, now)).toBe("now");
- expect(relativeTime(now - 59_000, now)).toBe("now");
- });
+ it("is 'now' within a minute", () => {
+ expect(relativeTime(now, now)).toBe("now");
+ expect(relativeTime(now - 59_000, now)).toBe("now");
+ });
- it("is minutes under an hour", () => {
- expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
- expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
- });
+ it("is minutes under an hour", () => {
+ expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
+ expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
+ });
- it("is hours under a day", () => {
- expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
- });
+ it("is hours under a day", () => {
+ expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
+ });
- it("is days under a week", () => {
- expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
- });
+ it("is days under a week", () => {
+ expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
+ });
- it("is a short date beyond a week", () => {
- // 7+ days ago: just check it is a MM/DD string.
- const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
- expect(s).toMatch(/^\d{2}\/\d{2}$/);
- });
+ it("is a short date beyond a week", () => {
+ // 7+ days ago: just check it is a MM/DD string.
+ const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
+ expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ });
});
describe("pageTitle", () => {
- // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
- const ws = (id: string, title: string): WorkspaceEntry => ({
- id,
- title,
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- conversationCount: 0,
- });
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
- it("is 'Dispatch' for the home route", () => {
- expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
- expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
- });
+ it("is 'Dispatch' for the home route", () => {
+ expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
+ expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
+ });
- it("is 'Dispatch: {title}' for a workspace with a display title", () => {
- const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
- expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
- });
+ it("is 'Dispatch: {title}' for a workspace with a display title", () => {
+ const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
+ expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
+ });
- it("falls back to the slug (id) until the list has loaded the workspace", () => {
- expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
- });
+ it("falls back to the slug (id) until the list has loaded the workspace", () => {
+ expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
+ });
- it("uses the id as the title when it was never customized (defaults to id)", () => {
- const list = [ws("default", "default")];
- expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
- });
+ it("uses the id as the title when it was never customized (defaults to id)", () => {
+ const list = [ws("default", "default")];
+ expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
+ });
- it("matches by id, not title", () => {
- const list = [ws("a", "shared-title"), ws("b", "shared-title")];
- expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
- });
+ it("matches by id, not title", () => {
+ const list = [ws("a", "shared-title"), ws("b", "shared-title")];
+ expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
+ });
});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
index e904514..6dd64c6 100644
--- a/src/features/workspaces/logic/view-model.ts
+++ b/src/features/workspaces/logic/view-model.ts
@@ -14,9 +14,9 @@ import type { Route } from "./route";
* workspaces in, string out.
*/
export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string {
- if (route.kind === "home") return "Dispatch";
- const ws = workspaces.find((w) => w.id === route.id);
- return `Dispatch: ${ws?.title ?? route.id}`;
+ if (route.kind === "home") return "Dispatch";
+ const ws = workspaces.find((w) => w.id === route.id);
+ return `Dispatch: ${ws?.title ?? route.id}`;
}
/**
@@ -25,17 +25,17 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]):
* (a workspace just created) read as "now".
*/
export function relativeTime(then: number, now: number): string {
- const diff = now - then;
- if (diff < 60_000) return "now";
- const mins = Math.floor(diff / 60_000);
- if (mins < 60) return `${mins}m`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h`;
- const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d`;
- // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
- const d = new Date(then);
- const month = String(d.getUTCMonth() + 1).padStart(2, "0");
- const day = String(d.getUTCDate()).padStart(2, "0");
- return `${month}/${day}`;
+ const diff = now - then;
+ if (diff < 60_000) return "now";
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
+ const d = new Date(then);
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${month}/${day}`;
}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 0cf4ce2..046c235 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -9,80 +9,80 @@ import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
* owned by the composition root.
*/
export interface WorkspaceStore {
- /** All workspaces (sorted by lastActivityAt desc by the backend). */
- readonly list: readonly WorkspaceEntry[];
- readonly loading: boolean;
- readonly error: string | null;
- /** Refresh the list from the backend. */
- refresh(): Promise<void>;
- /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
- ensure(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. */
- setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
- setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Delete a workspace (closes its conversations, reassigns to "default"). */
- remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ /** All workspaces (sorted by lastActivityAt desc by the backend). */
+ readonly list: readonly WorkspaceEntry[];
+ readonly loading: boolean;
+ readonly error: string | null;
+ /** Refresh the list from the backend. */
+ refresh(): Promise<void>;
+ /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
+ ensure(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. */
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Delete a workspace (closes its conversations, reassigns to "default"). */
+ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
- let list = $state<readonly WorkspaceEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
+ let list = $state<readonly WorkspaceEntry[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
- return {
- get list(): readonly WorkspaceEntry[] {
- return list;
- },
- get loading(): boolean {
- return loading;
- },
- get error(): string | null {
- return error;
- },
+ return {
+ get list(): readonly WorkspaceEntry[] {
+ return list;
+ },
+ get loading(): boolean {
+ return loading;
+ },
+ get error(): string | null {
+ return error;
+ },
- async refresh(): Promise<void> {
- loading = true;
- error = null;
- try {
- list = await http.list();
- } catch (err) {
- error = err instanceof Error ? err.message : "Failed to load workspaces";
- } finally {
- loading = false;
- }
- },
+ async refresh(): Promise<void> {
+ loading = true;
+ error = null;
+ try {
+ list = await http.list();
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load workspaces";
+ } finally {
+ loading = false;
+ }
+ },
- async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
- const result = await http.ensure(id, body);
- if (result.ok) void this.refresh();
- return result;
- },
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.ensure(id, body);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async rename(id, title): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setTitle(id, title);
- if (result.ok) void this.refresh();
- return result;
- },
+ async rename(id, title): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setTitle(id, title);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultCwd(id, defaultCwd);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultCwd(id, defaultCwd);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultComputer(id, computerId);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultComputer(id, computerId);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
- const result = await http.delete(id);
- if (result.ok) void this.refresh();
- return result;
- },
- };
+ async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ const result = await http.delete(id);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+ };
}
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index ff8a8ea..81b89e9 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -1,212 +1,205 @@
<script lang="ts">
- import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
- import type { WorkspaceStore } from "../store.svelte";
- import { relativeTime } from "../logic/view-model";
- import { workspacePath } from "../logic/route";
- import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
-
- let {
- ws,
- store,
- onNavigate,
- computers,
- }: {
- ws: WorkspaceEntry;
- store: WorkspaceStore;
- onNavigate: (path: string) => void;
- /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
- computers: readonly ComputerEntry[];
- } = $props();
-
- // ── Title: double-click to rename inline ──────────────────────────────────
- let editingTitle = $state(false);
- let titleDraft = $state("");
- let titleInput = $state<HTMLInputElement | undefined>();
- let titleError = $state<string | null>(null);
-
- function startEditTitle(): void {
- titleDraft = ws.title;
- titleError = null;
- editingTitle = true;
- queueMicrotask(() => titleInput?.focus());
- }
-
- async function saveTitle(): Promise<void> {
- if (!editingTitle) return;
- const title = titleDraft.trim();
- editingTitle = false;
- if (title === "" || title === ws.title) return;
- const result = await store.rename(ws.id, title);
- if (!result.ok) titleError = result.error;
- }
-
- function cancelTitle(): void {
- editingTitle = false;
- titleError = null;
- }
-
- // ── Default cwd: inline input ─────────────────────────────────────────────
- let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
- // Reseed when the backend value changes (e.g., after a save or an external
- // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
- $effect(() => {
- cwdDraft = ws.defaultCwd ?? "";
- });
-
- const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
- let savingCwd = $state(false);
- let cwdError = $state<string | null>(null);
-
- async function saveCwd(): Promise<void> {
- if (!cwdDirty || savingCwd) return;
- savingCwd = true;
- cwdError = null;
- const cwd = cwdDraft.trim();
- const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
- savingCwd = false;
- if (!result.ok) cwdError = result.error;
- }
-
- // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
- let savingComputer = $state(false);
- let computerError = $state<string | null>(null);
-
- async function saveComputer(computerId: string | null): Promise<void> {
- if (savingComputer) return;
- // No-op when unchanged (the select only fires on a real change, but guard).
- if (computerId === (ws.defaultComputerId ?? null)) return;
- savingComputer = true;
- computerError = null;
- const result = await store.setDefaultComputer(ws.id, computerId);
- savingComputer = false;
- if (!result.ok) computerError = result.error;
- }
-
- // ── Delete ─────────────────────────────────────────────────────────────────
- let deleting = $state(false);
-
- async function handleDelete(): Promise<void> {
- if (
- !window.confirm(
- `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
- )
- ) {
- return;
- }
- deleting = true;
- await store.remove(ws.id);
- deleting = false;
- }
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import { workspacePath } from "../logic/route";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
+
+ let {
+ ws,
+ store,
+ onNavigate,
+ computers,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ } = $props();
+
+ // ── Title: double-click to rename inline ──────────────────────────────────
+ let editingTitle = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEditTitle(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editingTitle = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ async function saveTitle(): Promise<void> {
+ if (!editingTitle) return;
+ const title = titleDraft.trim();
+ editingTitle = false;
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editingTitle = false;
+ titleError = null;
+ }
+
+ // ── Default cwd: inline input ─────────────────────────────────────────────
+ let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
+ // Reseed when the backend value changes (e.g., after a save or an external
+ // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
+ $effect(() => {
+ cwdDraft = ws.defaultCwd ?? "";
+ });
+
+ const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
+ let savingCwd = $state(false);
+ let cwdError = $state<string | null>(null);
+
+ async function saveCwd(): Promise<void> {
+ if (!cwdDirty || savingCwd) return;
+ savingCwd = true;
+ cwdError = null;
+ const cwd = cwdDraft.trim();
+ const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
+ savingCwd = false;
+ if (!result.ok) cwdError = result.error;
+ }
+
+ // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
+ let savingComputer = $state(false);
+ let computerError = $state<string | null>(null);
+
+ async function saveComputer(computerId: string | null): Promise<void> {
+ if (savingComputer) return;
+ // No-op when unchanged (the select only fires on a real change, but guard).
+ if (computerId === (ws.defaultComputerId ?? null)) return;
+ savingComputer = true;
+ computerError = null;
+ const result = await store.setDefaultComputer(ws.id, computerId);
+ savingComputer = false;
+ if (!result.ok) computerError = result.error;
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ let deleting = $state(false);
+
+ async function handleDelete(): Promise<void> {
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
+ return;
+ }
+ deleting = true;
+ await store.remove(ws.id);
+ deleting = false;
+ }
</script>
<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3">
- <div class="flex items-center gap-2">
- {#if editingTitle}
- <input
- bind:this={titleInput}
- bind:value={titleDraft}
- class="input input-bordered input-sm flex-1"
- aria-label="Workspace title"
- onkeydown={(e) => {
- if (e.key === "Enter") saveTitle();
- else if (e.key === "Escape") cancelTitle();
- }}
- onblur={saveTitle}
- />
- {:else}
- <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
- <span
- class="flex-1 cursor-default truncate font-semibold"
- title="Double-click to rename"
- ondblclick={startEditTitle}
- >{ws.title}</span>
- {/if}
- <span class="font-mono text-xs opacity-50">/{ws.id}</span>
- <span class="ml-auto text-xs opacity-50">
- {ws.conversationCount}
- {ws.conversationCount === 1 ? "conversation" : "conversations"}
- · {relativeTime(ws.lastActivityAt, Date.now())}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={deleting}
- title="Delete workspace"
- aria-label="Delete workspace"
- onclick={handleDelete}
- >
- {#if deleting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
-
- {#if titleError}
- <p class="text-xs text-error">{titleError}</p>
- {/if}
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
- <input
- type="text"
- class="input input-bordered input-sm flex-1 font-mono text-xs"
- placeholder="inherits the server default"
- bind:value={cwdDraft}
- aria-label="Default working directory"
- onkeydown={(e) => {
- if (e.key === "Enter") saveCwd();
- }}
- />
- <button
- type="button"
- class="btn btn-primary btn-xs"
- disabled={!cwdDirty || savingCwd}
- onclick={saveCwd}
- >
- {#if savingCwd}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
- <ComputerSelect
- value={ws.defaultComputerId}
- {computers}
- disabled={savingComputer}
- onSelect={saveComputer}
- />
- {#if savingComputer}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- <div class="flex justify-start">
- <a
- class="btn"
- href={workspacePath(ws.id)}
- target="_blank"
- rel="noopener noreferrer"
- >
- Open
- </a>
- </div>
-
- {#if cwdError}
- <p class="text-xs text-error">{cwdError}</p>
- {:else if !cwdDirty && !ws.defaultCwd}
- <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
- {/if}
-
- {#if computerError}
- <p class="text-xs text-error">{computerError}</p>
- {:else if !ws.defaultComputerId}
- <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
- {/if}
+ <div class="flex items-center gap-2">
+ {#if editingTitle}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm flex-1"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={saveTitle}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="flex-1 cursor-default truncate font-semibold"
+ title="Double-click to rename"
+ ondblclick={startEditTitle}>{ws.title}</span
+ >
+ {/if}
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+ <span class="ml-auto text-xs opacity-50">
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ · {relativeTime(ws.lastActivityAt, Date.now())}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={deleting}
+ title="Delete workspace"
+ aria-label="Delete workspace"
+ onclick={handleDelete}
+ >
+ {#if deleting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm flex-1 font-mono text-xs"
+ placeholder="inherits the server default"
+ bind:value={cwdDraft}
+ aria-label="Default working directory"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={saveCwd}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
+ <ComputerSelect
+ value={ws.defaultComputerId}
+ {computers}
+ disabled={savingComputer}
+ onSelect={saveComputer}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ <div class="flex justify-start">
+ <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a>
+ </div>
+
+ {#if cwdError}
+ <p class="text-xs text-error">{cwdError}</p>
+ {:else if !cwdDirty && !ws.defaultCwd}
+ <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
+ {/if}
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
</li>
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index f6ea432..0736efb 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -7,128 +7,128 @@ import type { WorkspaceStore } from "../store.svelte";
import WorkspaceCard from "./WorkspaceCard.svelte";
function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
- return {
- id: "my-ws",
- title: "My Workspace",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- ...overrides,
- };
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ ...overrides,
+ };
}
/** A fake store that records calls + resolves ok. */
function fakeStore() {
- return {
- rename: vi.fn(
- async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, title: _title }),
- }),
- ),
- setDefaultCwd: vi.fn(
- async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultCwd }),
- }),
- ),
- setDefaultComputer: vi.fn(
- async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultComputerId: computerId }),
- }),
- ),
- remove: vi.fn(
- async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
- ok: true,
- value: { closedCount: 0 },
- }),
- ),
- };
+ return {
+ rename: vi.fn(
+ async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title: _title }),
+ }),
+ ),
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ setDefaultComputer: vi.fn(
+ async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId: computerId }),
+ }),
+ ),
+ remove: vi.fn(
+ async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
+ ok: true,
+ value: { closedCount: 0 },
+ }),
+ ),
+ };
}
describe("WorkspaceCard", () => {
- it("renders the title, slug, and an Open link", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate, computers: [] },
- });
- expect(screen.getByText("My Workspace")).toBeInTheDocument();
- expect(screen.getByText("/my-ws")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
- });
-
- it("double-clicking the title reveals an edit input", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
- });
-
- it("renames via the store on Enter", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- const input = screen.getByLabelText("Workspace title");
- await user.clear(input);
- await user.type(input, "Renamed{Enter}");
-
- expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
- });
-
- it("enables Set only when the cwd differs, then saves it", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- expect(input).toHaveValue("/old");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, "/new/path");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
-
- await user.click(screen.getByRole("button", { name: "Set" }));
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
- });
-
- it("clears the cwd to null when saved empty (inherits the server default)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- await user.clear(input);
- await user.click(screen.getByRole("button", { name: "Set" }));
-
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
- });
-
- it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const onNavigate = vi.fn();
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
-
- const open = screen.getByRole("link", { name: "Open" });
- // Native new-tab link: href points at the workspace, and target="_blank"
- // opens it in a new browser tab rather than client-side navigating.
- expect(open).toHaveAttribute("href", "/my-ws");
- expect(open).toHaveAttribute("target", "_blank");
- expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
- });
+ it("renders the title, slug, and an Open link", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
+ });
+
+ it("double-clicking the title reveals an edit input", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
+ });
+
+ it("renames via the store on Enter", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ const input = screen.getByLabelText("Workspace title");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
+ });
+
+ it("enables Set only when the cwd differs, then saves it", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ expect(input).toHaveValue("/old");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+
+ await user.clear(input);
+ await user.type(input, "/new/path");
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+
+ await user.click(screen.getByRole("button", { name: "Set" }));
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
+ });
+
+ it("clears the cwd to null when saved empty (inherits the server default)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ await user.clear(input);
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
+ });
+
+ it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
+
+ const open = screen.getByRole("link", { name: "Open" });
+ // Native new-tab link: href points at the workspace, and target="_blank"
+ // opens it in a new browser tab rather than client-side navigating.
+ expect(open).toHaveAttribute("href", "/my-ws");
+ expect(open).toHaveAttribute("target", "_blank");
+ expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
+ });
});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index bdfd023..02e92b4 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,92 +1,97 @@
<script lang="ts">
- import { onMount } from "svelte";
- import type { ComputerEntry } from "@dispatch/wire";
- import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { onMount } from "svelte";
+ import type { ComputerEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { isValidSlug, workspacePath } from "../logic/route";
+ import WorkspaceCard from "./WorkspaceCard.svelte";
- let {
- store,
- onNavigate,
- computers,
- }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } =
- $props();
+ let {
+ store,
+ onNavigate,
+ computers,
+ }: {
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ computers: readonly ComputerEntry[];
+ } = $props();
- onMount(() => {
- void store.refresh();
- });
+ onMount(() => {
+ void store.refresh();
+ });
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
- return;
- }
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
- }
+ function createWorkspace(): void {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ newSlug = "";
+ onNavigate(workspacePath(slug));
+ }
</script>
<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
- <header class="flex items-center justify-between">
- <h1 class="text-2xl font-bold">Workspaces</h1>
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- </header>
+ <header class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold">Workspaces</h1>
+ <a
+ href="/default"
+ class="btn btn-ghost btn-sm"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/default");
+ }}
+ >
+ Default
+ </a>
+ </header>
- <form
- class="flex items-end gap-2"
- onsubmit={(e) => {
- e.preventDefault();
- createWorkspace();
- }}
- >
- <div class="flex-1">
- <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60">New workspace</label>
- <input
- id="new-ws"
- class="input input-bordered w-full"
- placeholder="my-workspace"
- bind:value={newSlug}
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
- </form>
- {#if slugError}
- <p class="text-xs text-error">{slugError}</p>
- {/if}
+ <form
+ class="flex items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ createWorkspace();
+ }}
+ >
+ <div class="flex-1">
+ <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60"
+ >New workspace</label
+ >
+ <input
+ id="new-ws"
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
+ </form>
+ {#if slugError}
+ <p class="text-xs text-error">{slugError}</p>
+ {/if}
- <div class="flex-1 overflow-y-auto">
- {#if store.loading && store.list.length === 0}
- <div class="flex h-32 items-center justify-center">
- <span class="loading loading-spinner loading-sm opacity-60"></span>
- </div>
- {:else if store.list.length === 0}
- <p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
- </p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} {computers} />
- {/each}
- </ul>
- {/if}
- </div>
+ <div class="flex-1 overflow-y-auto">
+ {#if store.loading && store.list.length === 0}
+ <div class="flex h-32 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else if store.list.length === 0}
+ <p class="py-8 text-center text-sm opacity-60">
+ No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ </p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.list as ws (ws.id)}
+ <WorkspaceCard {ws} {store} {onNavigate} {computers} />
+ {/each}
+ </ul>
+ {/if}
+ </div>
</div>