summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
commit38db3827870960f466be89afbc49f91238d46144 (patch)
tree24cb1b896dfadc31e72552dbe67f00530881242e /src/features/workspaces/logic
parent17ce47987e673b6618454d033885b17b2a01912e (diff)
downloaddispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz
dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing, store, http adapter, WorkspaceCard) wired into the App.svelte shell - rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature) - new features: mcp (status view), settings (chat-limit field), system-prompt (prompt builder), all rendered via the generic surface host - chat: store + ChatView updates - tabs: tabs-store updates - app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte This commit makes HEAD typecheck clean for the first time: the prior HEAD (c95cc77) imported features/settings from app/App.svelte but never committed the feature, so only the full working tree was green.
Diffstat (limited to 'src/features/workspaces/logic')
-rw-r--r--src/features/workspaces/logic/route.test.ts77
-rw-r--r--src/features/workspaces/logic/route.ts63
-rw-r--r--src/features/workspaces/logic/view-model.test.ts67
-rw-r--r--src/features/workspaces/logic/view-model.ts41
4 files changed, 248 insertions, 0 deletions
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
new file mode 100644
index 0000000..a6da3e1
--- /dev/null
+++ b/src/features/workspaces/logic/route.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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 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 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);
+ });
+});
+
+describe("workspacePath", () => {
+ 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
new file mode 100644
index 0000000..e30dc16
--- /dev/null
+++ b/src/features/workspaces/logic/route.ts
@@ -0,0 +1,63 @@
+/**
+ * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The app is URL-driven: the root path `/` is the workspaces HOME (lists all
+ * workspaces); a single path segment `/<id>` opens the workspace with that id
+ * (the slug). This module holds the pure mapping from a pathname to a `Route`,
+ * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`.
+ */
+
+/**
+ * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id`
+ * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only
+ * (no leading/trailing hyphen). `"default"` matches and is a valid (but
+ * non-deletable) id.
+ */
+export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
+
+/** A route derived from the URL path. */
+export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string };
+
+/** The reserved id of the always-present fallback workspace. */
+export const DEFAULT_WORKSPACE_ID = "default";
+
+/**
+ * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment →
+ * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper
+ * paths take their FIRST segment (nested routes are not used in v1). Pure:
+ * pathname in, route out. Does NOT validate the slug — an invalid id still
+ * produces a `workspace` route; the backend's ensure call rejects it (the FE
+ * 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 };
+}
+
+/**
+ * Whether a slug is valid for a NEW workspace (the form the backend accepts).
+ * Used by the home view's "new workspace" input before navigating.
+ */
+export function isValidSlug(slug: string): boolean {
+ return WORKSPACE_SLUG_RE.test(slug);
+}
+
+/**
+ * Build the URL path for a workspace id. Used when navigating to / linking a
+ * workspace. Pure: id in, path string out.
+ */
+export function workspacePath(id: string): string {
+ return `/${id}`;
+}
+
+function safeDecode(segment: string): string {
+ 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
new file mode 100644
index 0000000..52f7025
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -0,0 +1,67 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { pageTitle, relativeTime } from "./view-model";
+
+describe("relativeTime", () => {
+ 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 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 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}$/);
+ });
+});
+
+describe("pageTitle", () => {
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: 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: {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("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");
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
new file mode 100644
index 0000000..e904514
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.ts
@@ -0,0 +1,41 @@
+/**
+ * Pure view-model helpers for the workspaces feature — zero DOM, zero effects.
+ */
+
+import type { WorkspaceEntry } from "@dispatch/wire";
+import type { Route } from "./route";
+
+/**
+ * The browser tab / page (`document.title`) text for a route. The home route
+ * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}",
+ * using the workspace's display title and falling back to the URL slug (`id`)
+ * until the workspace list has loaded it — the backend defaults a workspace's
+ * title to its id, so the slug is the correct transient value. Pure: 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}`;
+}
+
+/**
+ * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h",
+ * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps
+ * (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}`;
+}