From 38db3827870960f466be89afbc49f91238d46144 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 10:55:51 +0900 Subject: 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. --- src/features/workspaces/ui/WorkspaceCard.svelte | 180 +++++++++++++++++++++++ src/features/workspaces/ui/WorkspaceCard.test.ts | 121 +++++++++++++++ src/features/workspaces/ui/WorkspacesHome.svelte | 86 +++++++++++ 3 files changed, 387 insertions(+) create mode 100644 src/features/workspaces/ui/WorkspaceCard.svelte create mode 100644 src/features/workspaces/ui/WorkspaceCard.test.ts create mode 100644 src/features/workspaces/ui/WorkspacesHome.svelte (limited to 'src/features/workspaces/ui') diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte new file mode 100644 index 0000000..6348bb9 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -0,0 +1,180 @@ + + +
  • +
    + {#if editingTitle} + { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + + {ws.title} + {/if} + /{ws.id} + + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + + +
    + + {#if titleError} +

    {titleError}

    + {/if} + +
    + cwd + { + if (e.key === "Enter") saveCwd(); + }} + /> + +
    + + + + {#if cwdError} +

    {cwdError}

    + {:else if !cwdDirty && !ws.defaultCwd} +

    No default cwd set — conversations inherit the server default.

    + {/if} +
  • diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts new file mode 100644 index 0000000..385856d --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -0,0 +1,121 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceCard from "./WorkspaceCard.svelte"; + +function fakeEntry(overrides: Partial = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: 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> => ({ + ok: true, + value: fakeEntry({ id, title: _title }), + }), + ), + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + remove: vi.fn( + async (): Promise> => ({ + ok: true, + value: { closedCount: 0 }, + }), + ), + }; +} + +describe("WorkspaceCard", () => { + it("renders the title, slug, and an Open link", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const onNavigate = vi.fn(); + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate }, + }); + 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() } }); + + 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() } }); + + 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() }, + }); + + 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() }, + }); + + 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 calls onNavigate with the workspace path (no full-card nav)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + const onNavigate = vi.fn(); + render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate } }); + + await user.click(screen.getByRole("link", { name: "Open" })); + expect(onNavigate).toHaveBeenCalledWith("/my-ws"); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte new file mode 100644 index 0000000..5894f0a --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -0,0 +1,86 @@ + + +
    +
    +

    Workspaces

    + { + e.preventDefault(); + onNavigate("/default"); + }} + > + Default + +
    + +
    { + e.preventDefault(); + createWorkspace(); + }} + > +
    + + +
    + +
    + {#if slugError} +

    {slugError}

    + {/if} + +
    + {#if store.loading && store.list.length === 0} +
    + +
    + {:else if store.list.length === 0} +

    + No workspaces yet. Create one above or visit /your-name in the URL. +

    + {:else} +
      + {#each store.list as ws (ws.id)} + + {/each} +
    + {/if} +
    +
    -- cgit v1.2.3