diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
| commit | 38db3827870960f466be89afbc49f91238d46144 (patch) | |
| tree | 24cb1b896dfadc31e72552dbe67f00530881242e /src/features/workspaces/logic | |
| parent | 17ce47987e673b6618454d033885b17b2a01912e (diff) | |
| download | dispatch-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.ts | 77 | ||||
| -rw-r--r-- | src/features/workspaces/logic/route.ts | 63 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 67 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 41 |
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}`; +} |
