diff --git a/docs/event-bus.md b/docs/event-bus.md index d975d54..bf2b526 100644 --- a/docs/event-bus.md +++ b/docs/event-bus.md @@ -307,7 +307,7 @@ import { EventBusProvider } from "@app-framework/core-ui"; function App() { return ( - + ); diff --git a/docs/superpowers/specs/2026-08-26-workspace-controller-widget.md b/docs/superpowers/specs/2026-08-26-workspace-controller-widget.md new file mode 100644 index 0000000..79e2f67 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-workspace-controller-widget.md @@ -0,0 +1,243 @@ +# Frontend Workspace Controller Widget — Design + +**Issue:** [#31](https://github.com/webscit/app-framework/issues/31) +**Status:** Design approved, pending implementation plan. +**Depends on:** [#30](https://github.com/webscit/app-framework/issues/30) — Backend workspace persistence API. + +**Goal:** Give applications built on the framework a default shell widget for +opening, saving, renaming, closing, and deleting a "workspace" (the persisted +application state defined by #30), wired to the backend workspace API and to +the existing shell layout store. + +**Tech stack:** TypeScript, React, Zustand, Vitest, `react-test-renderer`. + +--- + +## 1. Problem Statement + +Applications built with the framework display the shell's `LayoutProfilesMenu` +by default, which manages named local (browser `localStorage`) layout +presets. There is no widget for the new backend-owned "workspace" concept +(#30) at all — no way to create, open, save, or close a workspace from the UI. + +**What this solves:** a default shell menu, `WorkspaceMenu`, that performs the +full CRUD surface of the workspace API and keeps the shell layout in sync +with the active workspace's `layout_snapshot`. + +**What this does not solve (out of scope for this issue):** + +- The AI onboarding wizard shown on new-workspace creation (#36). +- Scenario storage or validation constraint UI (#35, #32). +- The "open workspace" AI notification (#37). +- Toast/notification infrastructure — errors are shown inline in the popover. +- Any change to `LayoutProfilesMenu` itself; it continues to manage local + named layout presets independently of workspaces. + +--- + +## 2. Scope + +**In scope:** + +- `packages/framework-core-ui/src/workspaceClient.ts` — typed `fetch` wrapper + around the 5 `/workspaces` REST endpoints from #30. +- `packages/framework-core-ui/src/stores/workspaceStore.ts` — Zustand store + holding workspace list + active workspace state and actions. +- `packages/framework-core-ui/src/components/WorkspaceMenu.tsx` (+ `.css`) — + the shell menu widget, mounted by default in `ShellRegions.tsx`. +- Auto-load of the last-active workspace on mount. +- Vitest unit tests for all three new modules. + +**Out of scope:** everything listed under "What this does not solve" above. + +--- + +## 3. Architecture + +### 3.1 `workspaceClient.ts` + +A thin `fetch`-wrapping module, structurally parallel to how +`EventBusContext.tsx` derives its WebSocket URL: + +```typescript +function buildWorkspaceApiUrl(locationLike: LocationLike): string { + const scheme = locationLike.protocol === "https:" ? "https" : "http"; + return `${scheme}://${locationLike.host}/workspaces`; +} + +export async function listWorkspaces(): Promise; +export async function createWorkspace(input: { name: string; goal?: string }): Promise; +export async function getWorkspace(id: string): Promise; +export async function updateWorkspace(id: string, workspace: Workspace): Promise; +export async function deleteWorkspace(id: string): Promise; +``` + +Each function throws a `WorkspaceApiError` (containing the HTTP status and +parsed detail message, when present) on a non-2xx response, so callers (the +store) can distinguish "not found" from other failures if ever needed, while +today the store treats all errors uniformly (see §3.4). + +`Workspace` / `WorkspaceSummary` TypeScript types mirror the Pydantic models +from #30 exactly (`id`, `name`, `created_at`, `updated_at`, `goal`, +`layout_snapshot`, `metadata`) and are exported from `index.ts`. + +### 3.2 `workspaceStore.ts` + +```typescript +interface WorkspaceStore { + workspaces: WorkspaceSummary[]; + activeWorkspaceId: string | null; + status: "idle" | "loading" | "error"; + error: string | null; + + refreshList: () => Promise; + createWorkspace: (name: string, goal?: string) => Promise; + openWorkspace: (id: string) => Promise; + save: () => Promise; + rename: (name: string) => Promise; + close: () => void; + deleteWorkspace: (id: string) => Promise; +} +``` + +Only `activeWorkspaceId` is persisted client-side, under the `localStorage` +key `app-framework:active-workspace-id` (namespaced like the existing +`app-framework:shell-layout` key). This is a plain read/write on that one +key, not the Zustand `persist` middleware — the store's other fields +(`workspaces`, `status`, `error`) are always re-fetched from the backend and +must never be treated as durable client state. + +The store holds the full active `Workspace` record (not just its id) +internally, to know what to send back on `save()`/`rename()`; it exposes +`activeWorkspaceId` (and the resolved summary from `workspaces`) as the +public read surface used by the UI to render the current name. + +### 3.3 `WorkspaceMenu.tsx` + +Same trigger-button + popover structure as `LayoutProfilesMenu.tsx`: + +- Trigger label: active workspace's name, or `"No workspace"` when + `activeWorkspaceId` is `null`. +- Popover, `role="menu"`: + - List of workspaces (`role="menuitemradio"`, checked = active) → clicking + calls `openWorkspace(id)`. + - Divider. + - **New workspace…** → inline name-entry form (same `Draft` pattern as + `LayoutProfilesMenu`) → `createWorkspace(name)`. + - **Save** → `save()`. Disabled when no workspace is active. + - **Rename** → inline edit form → `rename(name)`. Disabled when no + workspace is active. + - **Close** → `close()`. Disabled when no workspace is active. + - **Delete** → `deleteWorkspace(activeWorkspaceId)`. Disabled when no + workspace is active. +- On mount (`useEffect`, once): call `refreshList()`; if + `localStorage["app-framework:active-workspace-id"]` names a workspace + present in the fetched list, call `openWorkspace(id)` automatically. If the + stored id is stale (workspace no longer exists — 404), clear the stored key + and leave no workspace active, silently (no error shown for this specific + startup case). + +Placed in `ShellRegions.tsx` immediately after `LayoutProfilesMenu`, so every +app gets it by default, same as the layout menu. + +### 3.4 Layout coupling + +- `openWorkspace(id)`: fetches the full `Workspace`, then if + `layout_snapshot` is non-null, calls + `useShellLayoutStore.getState().setLayout(() => layout_snapshot as ShellLayout)`. + If `layout_snapshot` is null (workspace never saved with a layout), the + shell layout is left untouched. +- `save()`: reads `useShellLayoutStore.getState().layout`, assigns it to the + active workspace's `layout_snapshot`, and `PUT`s the full record. +- `close()`: clears `activeWorkspaceId` (store + `localStorage`), then calls + `useShellLayoutStore.getState().setLayout(() => createDefaultShellLayout())`. +- No new coupling is added to `shellStore.ts` itself — `workspaceStore.ts` is + the one that reaches into `useShellLayoutStore`'s public API + (`getState().layout`, `getState().setLayout`), keeping `shellStore` + unaware that workspaces exist. + +### 3.5 Error handling + +All store actions that call the API wrap the call in try/catch: on failure, +`status` is set to `"error"` and `error` holds a human-readable message (the +`WorkspaceApiError` message, or a generic fallback for network failures). +Store state (`workspaces`, `activeWorkspaceId`) is only updated after a +successful response — a failed `save()` leaves the previously-known state +intact. `WorkspaceMenu` renders `error` as inline text in the popover when +present, and clears it the next time any action is attempted. + +--- + +## 4. Public API Changes + +New exports from `packages/framework-core-ui/src/index.ts`: + +```typescript +export { WorkspaceMenu } from "./components/WorkspaceMenu"; +export { useWorkspaceStore } from "./stores/workspaceStore"; +export type { Workspace, WorkspaceSummary } from "./workspaceClient"; +``` + +No existing exports change shape. `ShellRegions.tsx`'s header now always +renders `WorkspaceMenu` alongside `LayoutProfilesMenu` — this is a visible +default-shell change but not a breaking API change (no new required props +anywhere). + +--- + +## 5. Testing + +- `workspaceClient.test.ts` — mocks `global.fetch`; covers each of the 5 + functions (success path + non-2xx → `WorkspaceApiError`). +- `workspaceStore.test.ts` — mocks `workspaceClient`; covers: + - `refreshList` populates `workspaces`. + - `createWorkspace` sets the new workspace active. + - `openWorkspace` sets active + applies `layout_snapshot` to + `shellStore` (asserted via `useShellLayoutStore.getState().layout`). + - `openWorkspace` leaves shell layout untouched when `layout_snapshot` is + null. + - `save` sends the current shell layout in the `PUT` body. + - `close` clears active id, clears the `localStorage` key, resets shell + layout to default. + - `deleteWorkspace` on the active workspace behaves like `close` afterward. + - A failed action sets `status: "error"` and leaves prior state intact. +- `WorkspaceMenu.test.tsx` — React-renderer tests (mirroring + `LayoutProfilesMenu.test.tsx`) covering: trigger shows "No workspace" + initially, opening the popover lists workspaces, "New workspace…" flow, + disabled state of Save/Rename/Close/Delete with no active workspace, and + auto-open-on-mount from a stubbed `localStorage` value. + +--- + +## 6. Implementation Checklist + +``` +- [ ] Task 1: workspaceClient.ts + - [ ] Workspace/WorkspaceSummary types mirroring #30's Pydantic models + - [ ] buildWorkspaceApiUrl + 5 fetch-wrapping functions + - [ ] WorkspaceApiError class + - [ ] Unit tests (mocked fetch) + +- [ ] Task 2: workspaceStore.ts + - [ ] Store shape + actions per §3.2 + - [ ] localStorage read/write of active workspace id + - [ ] Layout coupling per §3.4 + - [ ] Unit tests (mocked client, mocked shellStore) + +- [ ] Task 3: WorkspaceMenu.tsx + .css + - [ ] Trigger + popover structure mirroring LayoutProfilesMenu + - [ ] New/Open/Save/Rename/Close/Delete actions wired to store + - [ ] Auto-load-on-mount effect + - [ ] Inline error display + - [ ] Unit tests + +- [ ] Task 4: Wiring + - [ ] Mount WorkspaceMenu in ShellRegions.tsx header + - [ ] Export WorkspaceMenu, useWorkspaceStore, Workspace, WorkspaceSummary from index.ts + +- [ ] Task 5: Quality gate + - [ ] npm run typecheck + - [ ] npm run lint + - [ ] npm run test + - [ ] npm run format:check +``` diff --git a/examples/drone/backend/drone_example/main.py b/examples/drone/backend/drone_example/main.py index c544875..8575f0e 100644 --- a/examples/drone/backend/drone_example/main.py +++ b/examples/drone/backend/drone_example/main.py @@ -25,6 +25,7 @@ from fastapi import FastAPI from sci_framework_core import create_app from sci_framework_core.ai_layout import mount_ai_routes +from sci_framework_core.workspace import mount_workspace_routes from .consumers import register_consumers from .events import DroneLogEvent, DroneStateEvent @@ -76,3 +77,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = create_app(lifespan=lifespan) mount_ai_routes(app) +mount_workspace_routes(app, app_name="drone") diff --git a/examples/drone/frontend/main.tsx b/examples/drone/frontend/main.tsx index a98bd2b..1707f20 100644 --- a/examples/drone/frontend/main.tsx +++ b/examples/drone/frontend/main.tsx @@ -252,7 +252,7 @@ function DroneDashboard() { initialLayout={initialLayout} manifestUrl="/sct-manifest.json" ai={{ - apiUrl: "/ai/layout", + apiUrl: "/api/ai/layout", // The framework is domain-agnostic: this example supplies its own // context shape plus instructions describing what the data means and // which parameters the AI may change. @@ -273,7 +273,7 @@ function DroneDashboard() { function App() { return ( - + ); diff --git a/examples/drone/vite.config.ts b/examples/drone/vite.config.ts index ea75b77..986c9bf 100644 --- a/examples/drone/vite.config.ts +++ b/examples/drone/vite.config.ts @@ -38,16 +38,14 @@ export default defineConfig({ }, proxy: { // The drone example backend runs on port 8002 (Reachy uses 8001) — see - // examples/drone/backend/main.py for the run command. - "/ws": { + // examples/drone/backend/main.py for the run command. All backend + // routes (ws/ai/workspaces) are scoped under /api, so one proxy rule + // covers them. + "/api": { target: "http://127.0.0.1:8002", ws: true, changeOrigin: true, }, - "/ai": { - target: "http://127.0.0.1:8002", - changeOrigin: true, - }, }, }, }); diff --git a/examples/reachy_mini/backend/reachy_mini_example/main.py b/examples/reachy_mini/backend/reachy_mini_example/main.py index e5d1efd..ae13ede 100644 --- a/examples/reachy_mini/backend/reachy_mini_example/main.py +++ b/examples/reachy_mini/backend/reachy_mini_example/main.py @@ -25,6 +25,7 @@ from fastapi import FastAPI from sci_framework_core import create_app from sci_framework_core.ai_layout import mount_ai_routes +from sci_framework_core.workspace import mount_workspace_routes from .consumers import register_consumers from .producers import ( @@ -116,3 +117,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = create_app(lifespan=lifespan) mount_ai_routes(app) +mount_workspace_routes(app, app_name="reachy_mini") diff --git a/examples/reachy_mini/frontend/main.tsx b/examples/reachy_mini/frontend/main.tsx index 811abf9..6a1354a 100644 --- a/examples/reachy_mini/frontend/main.tsx +++ b/examples/reachy_mini/frontend/main.tsx @@ -222,7 +222,7 @@ function ReachyDashboard() { initialLayout={initialLayout} manifestUrl="/sct-manifest.json" ai={{ - apiUrl: "/ai/layout", + apiUrl: "/api/ai/layout", // The framework is domain-agnostic: this example supplies its own // context shape plus instructions describing what the data means and // which parameters the AI may change. @@ -243,7 +243,7 @@ function ReachyDashboard() { function App() { return ( - + ); diff --git a/examples/reachy_mini/vite.config.ts b/examples/reachy_mini/vite.config.ts index de38a07..9720860 100644 --- a/examples/reachy_mini/vite.config.ts +++ b/examples/reachy_mini/vite.config.ts @@ -41,15 +41,13 @@ export default defineConfig({ // (started via `mjpython -m reachy_mini.daemon.app.main --sim`) — the // example backend must run on a different port to avoid colliding // with it. See examples/reachy_mini/backend/main.py for the run command. - "/ws": { + // All backend routes (ws/ai/workspaces) are scoped under /api, so one + // proxy rule covers them. + "/api": { target: "http://127.0.0.1:8001", ws: true, changeOrigin: true, }, - "/ai": { - target: "http://127.0.0.1:8001", - changeOrigin: true, - }, }, }, }); diff --git a/examples/simple/backend/simple_example/main.py b/examples/simple/backend/simple_example/main.py index 6b65c63..aa4b00b 100644 --- a/examples/simple/backend/simple_example/main.py +++ b/examples/simple/backend/simple_example/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI from sci_framework_core import create_app from sci_framework_core.ai_layout import mount_ai_routes +from sci_framework_core.workspace import mount_workspace_routes from .consumers import register_consumers from .producers import ( @@ -41,3 +42,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = create_app(lifespan=lifespan) mount_ai_routes(app) +mount_workspace_routes(app, app_name="simple") diff --git a/examples/simple/frontend/main.tsx b/examples/simple/frontend/main.tsx index 3411df5..5ccb00d 100644 --- a/examples/simple/frontend/main.tsx +++ b/examples/simple/frontend/main.tsx @@ -118,11 +118,11 @@ const initialLayout: ShellLayout = { */ function App() { return ( - + ); diff --git a/examples/simple/vite.config.ts b/examples/simple/vite.config.ts index 7dbc398..d542aa8 100644 --- a/examples/simple/vite.config.ts +++ b/examples/simple/vite.config.ts @@ -37,15 +37,13 @@ export default defineConfig({ allow: [resolve(__dirname, "../../")], }, proxy: { - "/ws": { + // All backend routes (ws/ai/workspaces) are scoped under /api, so one + // proxy rule covers them. + "/api": { target: "http://127.0.0.1:8000", ws: true, changeOrigin: true, }, - "/ai": { - target: "http://127.0.0.1:8000", - changeOrigin: true, - }, }, }, }); diff --git a/packages/framework-core-ui/src/AppRoot.tsx b/packages/framework-core-ui/src/AppRoot.tsx index 6f510bc..39ad3d3 100644 --- a/packages/framework-core-ui/src/AppRoot.tsx +++ b/packages/framework-core-ui/src/AppRoot.tsx @@ -12,7 +12,7 @@ import type { WebSocketFactory } from "./client"; export interface AppRootProps { /** Widget registry shared with the shell and the AI assistant. */ registry: WidgetRegistry; - /** Backend WebSocket path. Defaults to `"/ws"`. */ + /** Backend WebSocket path. Defaults to `"/api/ws"`. */ webSocketPath?: string; /** Reconnect delay in milliseconds, forwarded to the EventBus client. */ reconnectDelayMs?: number; @@ -35,14 +35,14 @@ export interface AppRootProps { * @returns The children wrapped in the framework's context providers. * @example * ```tsx - * + * * * * ``` */ export function AppRoot({ registry, - webSocketPath = "/ws", + webSocketPath = "/api/ws", reconnectDelayMs, webSocketFactory, children, diff --git a/packages/framework-core-ui/src/ApplicationShell.test.tsx b/packages/framework-core-ui/src/ApplicationShell.test.tsx index d29a5ea..f10a865 100644 --- a/packages/framework-core-ui/src/ApplicationShell.test.tsx +++ b/packages/framework-core-ui/src/ApplicationShell.test.tsx @@ -329,7 +329,7 @@ describe("ApplicationShell", () => { const registry = new WidgetRegistry(); await render( - + , ); @@ -349,7 +349,7 @@ describe("ApplicationShell", () => { const registry = new WidgetRegistry(); await render( - + , ); diff --git a/packages/framework-core-ui/src/ApplicationShell.tsx b/packages/framework-core-ui/src/ApplicationShell.tsx index 8edeb36..dfac207 100644 --- a/packages/framework-core-ui/src/ApplicationShell.tsx +++ b/packages/framework-core-ui/src/ApplicationShell.tsx @@ -163,7 +163,7 @@ export interface ShellClassNames { * provider, the same as the text context in {@link AISnapshot}. */ export interface ShellAIConfig { - /** Layout-generation endpoint. Defaults to `"/ai/layout"`. */ + /** Layout-generation endpoint. Defaults to `"/api/ai/layout"`. */ apiUrl?: string; /** * Called before every request to attach an application-specific @@ -624,7 +624,7 @@ function ManifestGate({ * * ``` */ diff --git a/packages/framework-core-ui/src/ShellRegions.test.tsx b/packages/framework-core-ui/src/ShellRegions.test.tsx index 7e73930..af6634f 100644 --- a/packages/framework-core-ui/src/ShellRegions.test.tsx +++ b/packages/framework-core-ui/src/ShellRegions.test.tsx @@ -1,7 +1,7 @@ import React, { act } from "react"; import { page } from "vitest/browser"; import { render } from "vitest-browser-react"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApplicationShell } from "./ApplicationShell"; import { createDefaultShellLayout } from "./shellTypes"; @@ -16,6 +16,14 @@ import type { WidgetDefinition } from "./widgetRegistry"; // the test's initialLayout. beforeEach(() => { clearPersistedLayout(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -52,6 +60,18 @@ describe("ShellHeader", () => { await renderWithShell(registry); expect(page.getByRole("banner").query()).not.toBeNull(); }); + + it("renders the WorkspaceMenu trigger alongside LayoutProfilesMenu", async () => { + const registry = new WidgetRegistry(); + await renderWithShell(registry); + + await expect + .element(page.getByRole("button", { name: /layout profile/i })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toBeInTheDocument(); + }); }); describe("ShellSidebar", () => { diff --git a/packages/framework-core-ui/src/ShellRegions.tsx b/packages/framework-core-ui/src/ShellRegions.tsx index cbff44c..1eeff9f 100644 --- a/packages/framework-core-ui/src/ShellRegions.tsx +++ b/packages/framework-core-ui/src/ShellRegions.tsx @@ -6,6 +6,7 @@ import { mergeClassNames } from "./helpers"; import type { RegionItem, RegionSetter, RegionState } from "./shellTypes"; import { useWidgetRegistryInstance } from "./WidgetRegistryContext"; import { LayoutProfilesMenu } from "./components/LayoutProfilesMenu"; +import { WorkspaceMenu } from "./components/WorkspaceMenu"; // ─── RegionItemRenderer ─────────────────────────────────────────────────────── @@ -97,6 +98,7 @@ export function ShellHeader({ data-testid="shell-header" > + {sorted.map((item) => ( ))} diff --git a/packages/framework-core-ui/src/components/AIChatPanel.test.tsx b/packages/framework-core-ui/src/components/AIChatPanel.test.tsx index befb958..e902365 100644 --- a/packages/framework-core-ui/src/components/AIChatPanel.test.tsx +++ b/packages/framework-core-ui/src/components/AIChatPanel.test.tsx @@ -102,7 +102,7 @@ function defaultProps(overrides?: Partial[0]>) { currentLayout: BASE_LAYOUT, onApplyLayout: vi.fn(), registry: makeRegistry(), - apiUrl: "/ai/layout", + apiUrl: "/api/ai/layout", ...overrides, }; } @@ -346,7 +346,7 @@ describe("AIChatPanel", () => { expect(fetch).toHaveBeenCalledOnce(); const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; - expect(url).toBe("/ai/layout"); + expect(url).toBe("/api/ai/layout"); const body = JSON.parse(init.body as string) as { prompt: string; diff --git a/packages/framework-core-ui/src/components/AIChatPanel.tsx b/packages/framework-core-ui/src/components/AIChatPanel.tsx index eb6b0bf..fc5dca3 100644 --- a/packages/framework-core-ui/src/components/AIChatPanel.tsx +++ b/packages/framework-core-ui/src/components/AIChatPanel.tsx @@ -85,7 +85,7 @@ export interface AIChatPanelProps { registry: WidgetRegistry; /** * Base URL for the layout generation endpoint. - * Defaults to `"/ai/layout"`. + * Defaults to `"/api/ai/layout"`. */ apiUrl?: string; /** @@ -265,7 +265,7 @@ function MessageBubble({ * shell layout generation. * * The panel serialises the widget registry and the approved conversation history - * and POSTs them to `POST /ai/layout`. When the AI proposes a layout, a + * and POSTs them to `POST /api/ai/layout`. When the AI proposes a layout, a * {@link LayoutDiffViewer} is rendered inline inside the assistant bubble so the * user can Approve or Reject the change. Only approved turns are included in * subsequent requests so rejected proposals do not pollute the AI's context. @@ -290,7 +290,7 @@ export function AIChatPanel({ currentLayout, onApplyLayout, registry, - apiUrl = "/ai/layout", + apiUrl = "/api/ai/layout", getSnapshot, onApproveParams, getCaptureTarget, diff --git a/packages/framework-core-ui/src/components/WorkspaceMenu.css b/packages/framework-core-ui/src/components/WorkspaceMenu.css new file mode 100644 index 0000000..2bc7eab --- /dev/null +++ b/packages/framework-core-ui/src/components/WorkspaceMenu.css @@ -0,0 +1,108 @@ +.sct-WorkspaceMenu { + position: relative; + display: inline-flex; + font-family: var(--font-sans, sans-serif); + font-size: 13px; +} + +/* ─── Trigger ─────────────────────────────────────────────────────────────── */ + +.sct-WorkspaceMenu-trigger { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--card); + color: var(--foreground); + font-size: 13px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.sct-WorkspaceMenu-trigger:hover { + background-color: var(--muted); +} + +.sct-WorkspaceMenu-triggerLabel { + font-weight: 500; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sct-WorkspaceMenu-caret { + width: 14px; + height: 14px; + color: var(--muted-foreground); +} + +/* ─── Popover ─────────────────────────────────────────────────────────────── */ +/* + * The popover shell and menu items themselves come from the shared + * DropdownMenu primitives (see components/ui/dropdown-menu.tsx); only the + * width is customized here. + */ + +.sct-WorkspaceMenu-popover { + min-width: 224px; +} + +/* ─── Inline name form (create / rename) ────────────────────────────────────── */ + +.sct-WorkspaceMenu-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 8px 6px 6px; +} + +.sct-WorkspaceMenu-formLabel { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.sct-WorkspaceMenu-input { + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 7px; + background-color: var(--background); + color: var(--foreground); + font-size: 13px; + outline: none; +} + +.sct-WorkspaceMenu-input:focus-visible { + border-color: var(--ring, var(--primary)); + box-shadow: 0 0 0 3px + color-mix(in oklch, var(--ring, var(--primary)) 25%, transparent); +} + +.sct-WorkspaceMenu-formActions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 2px; +} + +.sct-WorkspaceMenu-formActions > button { + padding-inline: 12px; +} + +/* ─── Inline error ────────────────────────────────────────────────────────── */ + +.sct-WorkspaceMenu-error { + margin: 4px 2px 0; + padding: 8px 10px; + border-radius: 6px; + background-color: color-mix(in oklch, var(--destructive) 12%, transparent); + color: var(--destructive); + font-size: 12px; +} diff --git a/packages/framework-core-ui/src/components/WorkspaceMenu.test.tsx b/packages/framework-core-ui/src/components/WorkspaceMenu.test.tsx new file mode 100644 index 0000000..548691f --- /dev/null +++ b/packages/framework-core-ui/src/components/WorkspaceMenu.test.tsx @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; + +import { createDefaultShellLayout } from "../shellTypes"; +import { useShellLayoutStore } from "../stores/shellStore"; +import { + ACTIVE_WORKSPACE_STORAGE_KEY, + useWorkspaceStore, +} from "../stores/workspaceStore"; +import * as workspaceClient from "../workspaceClient"; +import type { Workspace, WorkspaceSummary } from "../workspaceClient"; +import { WorkspaceMenu } from "./WorkspaceMenu"; + +vi.mock("../workspaceClient", async () => { + const actual = + await vi.importActual("../workspaceClient"); + return { + ...actual, + listWorkspaces: vi.fn(), + createWorkspace: vi.fn(), + getWorkspace: vi.fn(), + updateWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + }; +}); + +const SUMMARY: WorkspaceSummary = { + id: "w1", + name: "Drone survey", + created_at: "2026-08-26T00:00:00Z", + updated_at: "2026-08-26T00:00:00Z", +}; + +const WORKSPACE: Workspace = { + ...SUMMARY, + goal: null, + layout_snapshot: null, + metadata: {}, +}; + +beforeEach(() => { + localStorage.clear(); + const layout = createDefaultShellLayout(); + useShellLayoutStore.setState({ + profiles: [{ id: "p1", name: "Default", layout }], + activeProfileId: "p1", + workingLayout: structuredClone(layout), + defaultLayout: structuredClone(layout), + }); + useWorkspaceStore.setState({ + workspaces: [], + activeWorkspaceId: null, + activeWorkspace: null, + status: "idle", + error: null, + }); + vi.mocked(workspaceClient.listWorkspaces).mockReset().mockResolvedValue([]); + vi.mocked(workspaceClient.createWorkspace).mockReset(); + vi.mocked(workspaceClient.getWorkspace).mockReset(); + vi.mocked(workspaceClient.updateWorkspace).mockReset(); + vi.mocked(workspaceClient.deleteWorkspace).mockReset(); +}); + +async function openMenu() { + await page.getByRole("button", { name: /workspace/i }).click(); +} + +describe("WorkspaceMenu", () => { + it("labels the trigger 'No workspace' before any workspace is active", async () => { + await render(); + + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("No workspace"); + }); + + it("lists every workspace as a menu item when opened", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([ + SUMMARY, + { ...SUMMARY, id: "w2", name: "Bridge inspection" }, + ]); + + await render(); + await openMenu(); + + await expect + .element(page.getByRole("menuitemradio", { name: "Drone survey" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("menuitemradio", { name: "Bridge inspection" })) + .toBeInTheDocument(); + }); + + it("opens a workspace when selected from the list", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue(WORKSPACE); + + await render(); + await openMenu(); + await page.getByRole("menuitemradio", { name: "Drone survey" }).click(); + + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("Drone survey"); + }); + + it("creates a workspace through the New workspace… form", async () => { + vi.mocked(workspaceClient.createWorkspace).mockResolvedValue(WORKSPACE); + + await render(); + await openMenu(); + await page.getByRole("menuitem", { name: "New workspace…" }).click(); + await page.getByRole("textbox", { name: "Workspace name" }).fill("Drone survey"); + await page.getByRole("button", { name: "Create workspace" }).click(); + + expect(workspaceClient.createWorkspace).toHaveBeenCalledWith({ + name: "Drone survey", + goal: undefined, + }); + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("Drone survey"); + }); + + it("disables Save, Rename, Close, and Delete when no workspace is active", async () => { + await render(); + await openMenu(); + + await expect.element(page.getByRole("menuitem", { name: "Save" })).toBeDisabled(); + await expect.element(page.getByRole("menuitem", { name: "Rename" })).toBeDisabled(); + await expect.element(page.getByRole("menuitem", { name: "Close" })).toBeDisabled(); + await expect.element(page.getByRole("menuitem", { name: "Delete" })).toBeDisabled(); + }); + + it("auto-opens the workspace named by localStorage on mount", async () => { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, "w1"); + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue(WORKSPACE); + + await render(); + + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("Drone survey"); + }); + + it("clears a stale localStorage id without erroring when the workspace no longer exists", async () => { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, "stale-id"); + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + + await render(); + + expect(workspaceClient.getWorkspace).not.toHaveBeenCalled(); + expect(localStorage.getItem(ACTIVE_WORKSPACE_STORAGE_KEY)).toBeNull(); + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("No workspace"); + }); + + it("shows an inline error message when an action fails", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockRejectedValue( + new Error("network down"), + ); + + await render(); + await openMenu(); + + await expect.element(page.getByRole("alert")).toHaveTextContent("network down"); + }); + + it("does not clear a valid stored id when the initial fetch fails", async () => { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, "w1"); + vi.mocked(workspaceClient.listWorkspaces).mockRejectedValue( + new Error("network down"), + ); + + await render(); + + expect(workspaceClient.getWorkspace).not.toHaveBeenCalled(); + expect(localStorage.getItem(ACTIVE_WORKSPACE_STORAGE_KEY)).toBe("w1"); + }); + + it("renames the active workspace through the Rename form", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue(WORKSPACE); + + await render(); + await openMenu(); + await page.getByRole("menuitemradio", { name: "Drone survey" }).click(); + await openMenu(); + await page.getByRole("menuitem", { name: "Rename" }).click(); + + vi.mocked(workspaceClient.updateWorkspace).mockResolvedValue({ + ...WORKSPACE, + name: "Renamed survey", + }); + + await page.getByRole("textbox", { name: "Workspace name" }).fill("Renamed survey"); + await page.getByRole("button", { name: "Save name" }).click(); + + await expect + .element(page.getByRole("button", { name: /workspace/i })) + .toHaveTextContent("Renamed survey"); + }); + + it("keeps the menu open and shows the error when Save fails", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue(WORKSPACE); + vi.mocked(workspaceClient.updateWorkspace).mockRejectedValue( + new Error("save failed"), + ); + + await render(); + await openMenu(); + await page.getByRole("menuitemradio", { name: "Drone survey" }).click(); + await openMenu(); + await page.getByRole("menuitem", { name: "Save" }).click(); + + await expect.element(page.getByRole("alert")).toHaveTextContent("save failed"); + await expect + .element(page.getByRole("menuitem", { name: "Save" })) + .toBeInTheDocument(); + }); +}); diff --git a/packages/framework-core-ui/src/components/WorkspaceMenu.tsx b/packages/framework-core-ui/src/components/WorkspaceMenu.tsx new file mode 100644 index 0000000..ae7aa53 --- /dev/null +++ b/packages/framework-core-ui/src/components/WorkspaceMenu.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from "react"; +import type React from "react"; +import { ChevronDownIcon } from "lucide-react"; + +import { + clearStoredActiveWorkspaceId, + readStoredActiveWorkspaceId, + useWorkspaceStore, +} from "../stores/workspaceStore"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import "./WorkspaceMenu.css"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Which inline text-entry form (if any) the menu is currently showing. */ +type Draft = + | { mode: "idle" } + | { mode: "creating"; name: string } + | { mode: "renaming"; name: string }; + +// ─── WorkspaceMenu ────────────────────────────────────────────────────────────── + +/** + * Header control for creating, opening, saving, renaming, closing, and + * deleting a backend-persisted workspace. + * + * Shows the active workspace's name (or `"No workspace"`) as a menu trigger. + * Opening it lists every workspace (selecting one calls `openWorkspace`) plus + * actions to **New workspace…** (inline name entry → `createWorkspace`), + * **Save** the active workspace's current shell layout (→ `save`), + * **Rename** it (inline edit → `rename`), **Close** it (→ `close`), and + * **Delete** it (→ `deleteWorkspace`). Save/Rename/Close/Delete are disabled + * when no workspace is active. + * + * On mount, the workspace list is fetched and, if `localStorage` names a + * workspace still present in that list, it is opened automatically; a stale + * id (a workspace deleted elsewhere) is cleared silently. If the initial + * list fetch itself fails, the stored id is left untouched (rather than + * cleared) since we don't yet know whether it's actually stale. + * + * The component is a thin consumer of the workspace store and holds no + * workspace state of its own — {@link useWorkspaceStore} is the single + * source of truth. It is placed in the shell header by `ShellRegions`, next + * to `LayoutProfilesMenu` (which continues to manage local layout presets + * independently of workspaces). Its menu is built on the shared + * {@link DropdownMenu} primitives rather than a bespoke popover. + * + * @returns The workspace menu element. + * @example + * ```tsx + * // Rendered inside the shell header: + * + * ``` + */ +export function WorkspaceMenu(): React.ReactElement { + const workspaces = useWorkspaceStore((s) => s.workspaces); + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId); + const activeWorkspace = useWorkspaceStore((s) => s.activeWorkspace); + const error = useWorkspaceStore((s) => s.error); + const refreshList = useWorkspaceStore((s) => s.refreshList); + const createWorkspace = useWorkspaceStore((s) => s.createWorkspace); + const openWorkspace = useWorkspaceStore((s) => s.openWorkspace); + const save = useWorkspaceStore((s) => s.save); + const rename = useWorkspaceStore((s) => s.rename); + const close = useWorkspaceStore((s) => s.close); + const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace); + + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState({ mode: "idle" }); + + useEffect(() => { + async function init(): Promise { + await refreshList(); + const storedId = readStoredActiveWorkspaceId(); + if (!storedId) return; + const state = useWorkspaceStore.getState(); + if (state.status === "error") return; + const exists = state.workspaces.some((w) => w.id === storedId); + if (exists) { + await openWorkspace(storedId); + } else { + clearStoredActiveWorkspaceId(); + } + } + void init(); + // Runs once on mount only; refreshList/openWorkspace are stable store actions. + }, []); + + const active = activeWorkspace ?? workspaces.find((w) => w.id === activeWorkspaceId); + const hasActive = active !== undefined; + + function closeMenu(): void { + setOpen(false); + setDraft({ mode: "idle" }); + } + + async function handleSelect(id: string): Promise { + const ok = await openWorkspace(id); + if (ok) closeMenu(); + } + + async function handleSave(): Promise { + const ok = await save(); + if (ok) closeMenu(); + } + + async function handleDelete(): Promise { + if (!activeWorkspaceId) return; + const ok = await deleteWorkspace(activeWorkspaceId); + if (ok) closeMenu(); + } + + async function submitDraft(): Promise { + let ok = true; + if (draft.mode === "creating") { + ok = await createWorkspace(draft.name); + } else if (draft.mode === "renaming") { + ok = await rename(draft.name); + } + if (ok) closeMenu(); + } + + return ( + + + + {active ? active.name : "No workspace"} + + + + + + {draft.mode === "idle" ? ( + <> + void handleSelect(value as string)} + > + {workspaces.map((w) => ( + + {w.name} + + ))} + + + + + setDraft({ mode: "creating", name: "" })} + > + New workspace… + + void handleSave()} + > + Save + + setDraft({ mode: "renaming", name: active?.name ?? "" })} + > + Rename + + { + close(); + closeMenu(); + }} + > + Close + + void handleDelete()} + > + Delete + + + ) : ( +
+ + {draft.mode === "creating" ? "New workspace name" : "Rename workspace"} + + setDraft({ ...draft, name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submitDraft(); + } else if (e.key === "Escape") { + setDraft({ mode: "idle" }); + } + }} + /> +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/packages/framework-core-ui/src/components/ui/dropdown-menu.tsx b/packages/framework-core-ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b7d98d8 --- /dev/null +++ b/packages/framework-core-ui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,259 @@ +import * as React from "react"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { ChevronRightIcon, CheckIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return ; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/packages/framework-core-ui/src/index.ts b/packages/framework-core-ui/src/index.ts index 28ffad9..be51027 100644 --- a/packages/framework-core-ui/src/index.ts +++ b/packages/framework-core-ui/src/index.ts @@ -75,6 +75,14 @@ export { type ShellLayoutStore, } from "./stores/shellStore"; export { LayoutProfilesMenu } from "./components/LayoutProfilesMenu"; +export { WorkspaceMenu } from "./components/WorkspaceMenu"; +export { useWorkspaceStore, type WorkspaceStore } from "./stores/workspaceStore"; +export { + buildWorkspaceApiUrl, + WorkspaceApiError, + type Workspace, + type WorkspaceSummary, +} from "./workspaceClient"; export { useProfileState } from "./useProfileState"; export { registerProfileState, diff --git a/packages/framework-core-ui/src/stores/workspaceStore.test.ts b/packages/framework-core-ui/src/stores/workspaceStore.test.ts new file mode 100644 index 0000000..7565b23 --- /dev/null +++ b/packages/framework-core-ui/src/stores/workspaceStore.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDefaultShellLayout, SHELL_LAYOUT_STORAGE_VERSION } from "../shellTypes"; +import * as workspaceClient from "../workspaceClient"; +import type { Workspace, WorkspaceSummary } from "../workspaceClient"; +import { useShellLayoutStore } from "./shellStore"; +import { + ACTIVE_WORKSPACE_STORAGE_KEY, + clearStoredActiveWorkspaceId, + readStoredActiveWorkspaceId, + useWorkspaceStore, +} from "./workspaceStore"; + +vi.mock("../workspaceClient", async () => { + const actual = + await vi.importActual("../workspaceClient"); + return { + ...actual, + listWorkspaces: vi.fn(), + createWorkspace: vi.fn(), + getWorkspace: vi.fn(), + updateWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + }; +}); + +const SUMMARY: WorkspaceSummary = { + id: "w1", + name: "Drone survey", + created_at: "2026-08-26T00:00:00Z", + updated_at: "2026-08-26T00:00:00Z", +}; + +const WORKSPACE: Workspace = { + ...SUMMARY, + goal: null, + layout_snapshot: null, + metadata: {}, +}; + +function resetShellLayout(): void { + const layout = createDefaultShellLayout(); + useShellLayoutStore.setState({ + profiles: [{ id: "p1", name: "Default", layout }], + activeProfileId: "p1", + workingLayout: structuredClone(layout), + defaultLayout: structuredClone(layout), + }); +} + +beforeEach(() => { + localStorage.clear(); + resetShellLayout(); + useWorkspaceStore.setState({ + workspaces: [], + activeWorkspaceId: null, + activeWorkspace: null, + status: "idle", + error: null, + }); + vi.mocked(workspaceClient.listWorkspaces).mockReset(); + vi.mocked(workspaceClient.createWorkspace).mockReset(); + vi.mocked(workspaceClient.getWorkspace).mockReset(); + vi.mocked(workspaceClient.updateWorkspace).mockReset(); + vi.mocked(workspaceClient.deleteWorkspace).mockReset(); +}); + +describe("useWorkspaceStore", () => { + it("refreshList populates workspaces from the API", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + + await useWorkspaceStore.getState().refreshList(); + + expect(useWorkspaceStore.getState().workspaces).toEqual([SUMMARY]); + expect(useWorkspaceStore.getState().status).toBe("idle"); + }); + + it("createWorkspace adds the workspace and makes it active", async () => { + vi.mocked(workspaceClient.createWorkspace).mockResolvedValue(WORKSPACE); + + await useWorkspaceStore.getState().createWorkspace("Drone survey"); + + const state = useWorkspaceStore.getState(); + expect(state.activeWorkspaceId).toBe("w1"); + expect(state.workspaces).toEqual([SUMMARY]); + expect(readStoredActiveWorkspaceId()).toBe("w1"); + }); + + it("openWorkspace applies layout_snapshot to the shell layout store", async () => { + const layout = createDefaultShellLayout(); + layout.regions.header.visible = false; + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue({ + ...WORKSPACE, + layout_snapshot: layout, + metadata: { shellLayoutVersion: SHELL_LAYOUT_STORAGE_VERSION }, + }); + + await useWorkspaceStore.getState().openWorkspace("w1"); + + expect(useWorkspaceStore.getState().activeWorkspaceId).toBe("w1"); + expect(useShellLayoutStore.getState().workingLayout.regions.header.visible).toBe( + false, + ); + }); + + it("openWorkspace leaves the shell layout untouched when layout_snapshot is null", async () => { + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue(WORKSPACE); + const before = useShellLayoutStore.getState().workingLayout; + + await useWorkspaceStore.getState().openWorkspace("w1"); + + expect(useShellLayoutStore.getState().workingLayout).toEqual(before); + }); + + it("save sends the current shell layout in the update request", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + useShellLayoutStore.getState().setLayout((prev) => { + prev.regions.header.visible = false; + return prev; + }); + const layout = useShellLayoutStore.getState().workingLayout; + vi.mocked(workspaceClient.updateWorkspace).mockResolvedValue({ + ...WORKSPACE, + layout_snapshot: layout, + }); + + await useWorkspaceStore.getState().save(); + + expect(workspaceClient.updateWorkspace).toHaveBeenCalledWith( + "w1", + expect.objectContaining({ layout_snapshot: layout }), + ); + }); + + it("close clears the active workspace and resets the shell layout", () => { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, "w1"); + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + useShellLayoutStore.getState().setLayout((prev) => { + prev.regions.header.visible = false; + return prev; + }); + + useWorkspaceStore.getState().close(); + + expect(useWorkspaceStore.getState().activeWorkspaceId).toBeNull(); + expect(readStoredActiveWorkspaceId()).toBeNull(); + expect(useShellLayoutStore.getState().workingLayout.regions.header.visible).toBe( + true, + ); + }); + + it("deleteWorkspace on the active workspace clears it and resets the shell layout", async () => { + useWorkspaceStore.setState({ + workspaces: [SUMMARY], + activeWorkspaceId: "w1", + activeWorkspace: WORKSPACE, + }); + useShellLayoutStore.getState().setLayout((prev) => { + prev.regions.header.visible = false; + return prev; + }); + vi.mocked(workspaceClient.deleteWorkspace).mockResolvedValue(undefined); + + await useWorkspaceStore.getState().deleteWorkspace("w1"); + + const state = useWorkspaceStore.getState(); + expect(state.workspaces).toEqual([]); + expect(state.activeWorkspaceId).toBeNull(); + expect(useShellLayoutStore.getState().workingLayout.regions.header.visible).toBe( + true, + ); + }); + + it("deleteWorkspace on an inactive workspace leaves the active one untouched", async () => { + useWorkspaceStore.setState({ + workspaces: [SUMMARY, { ...SUMMARY, id: "w2", name: "Other" }], + activeWorkspaceId: "w1", + activeWorkspace: WORKSPACE, + }); + vi.mocked(workspaceClient.deleteWorkspace).mockResolvedValue(undefined); + + await useWorkspaceStore.getState().deleteWorkspace("w2"); + + const state = useWorkspaceStore.getState(); + expect(state.workspaces).toEqual([SUMMARY]); + expect(state.activeWorkspaceId).toBe("w1"); + }); + + it("rename updates the active workspace's name", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + vi.mocked(workspaceClient.updateWorkspace).mockResolvedValue({ + ...WORKSPACE, + name: "Renamed", + }); + + await useWorkspaceStore.getState().rename("Renamed"); + + expect(useWorkspaceStore.getState().activeWorkspace?.name).toBe("Renamed"); + expect(workspaceClient.updateWorkspace).toHaveBeenCalledWith( + "w1", + expect.objectContaining({ name: "Renamed" }), + ); + }); + + it("rename is a no-op when no workspace is active", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: null, activeWorkspace: null }); + + await useWorkspaceStore.getState().rename("New name"); + + expect(workspaceClient.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("rename is a no-op when given a blank name", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + + await useWorkspaceStore.getState().rename(" "); + + expect(workspaceClient.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("save is a no-op when no workspace is active", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: null, activeWorkspace: null }); + + await useWorkspaceStore.getState().save(); + + expect(workspaceClient.updateWorkspace).not.toHaveBeenCalled(); + }); + + it("clearStoredActiveWorkspaceId clears the stored key", () => { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, "w1"); + + clearStoredActiveWorkspaceId(); + + expect(readStoredActiveWorkspaceId()).toBeNull(); + }); + + it("a failed action sets status to error and leaves prior state intact", async () => { + useWorkspaceStore.setState({ workspaces: [SUMMARY] }); + vi.mocked(workspaceClient.listWorkspaces).mockRejectedValue( + new workspaceClient.WorkspaceApiError(500, "server exploded"), + ); + + await useWorkspaceStore.getState().refreshList(); + + const state = useWorkspaceStore.getState(); + expect(state.status).toBe("error"); + expect(state.error).toBe("server exploded"); + expect(state.workspaces).toEqual([SUMMARY]); + }); + + it("a success action clears a previously-set error", async () => { + useWorkspaceStore.setState({ status: "error", error: "stale" }); + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + + await useWorkspaceStore.getState().refreshList(); + + expect(useWorkspaceStore.getState().error).toBeNull(); + }); + + it("close resets status and error", () => { + useWorkspaceStore.setState({ status: "error", error: "stale" }); + + useWorkspaceStore.getState().close(); + + expect(useWorkspaceStore.getState().status).toBe("idle"); + expect(useWorkspaceStore.getState().error).toBeNull(); + }); + + it("refreshList returns false on failure and true on success", async () => { + vi.mocked(workspaceClient.listWorkspaces).mockRejectedValue( + new Error("network down"), + ); + await expect(useWorkspaceStore.getState().refreshList()).resolves.toBe(false); + + vi.mocked(workspaceClient.listWorkspaces).mockResolvedValue([SUMMARY]); + await expect(useWorkspaceStore.getState().refreshList()).resolves.toBe(true); + }); + + it("save stamps the current SHELL_LAYOUT_STORAGE_VERSION into the record it sends", async () => { + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + vi.mocked(workspaceClient.updateWorkspace).mockResolvedValue(WORKSPACE); + + await useWorkspaceStore.getState().save(); + + expect(workspaceClient.updateWorkspace).toHaveBeenCalledWith( + "w1", + expect.objectContaining({ + metadata: expect.objectContaining({ + ["shellLayoutVersion"]: SHELL_LAYOUT_STORAGE_VERSION, + }), + }), + ); + }); + + it("openWorkspace skips applying a layout_snapshot with a mismatched version stamp", async () => { + const layout = createDefaultShellLayout(); + layout.regions.header.visible = false; + const before = useShellLayoutStore.getState().workingLayout; + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue({ + ...WORKSPACE, + layout_snapshot: layout, + metadata: { ["shellLayoutVersion"]: SHELL_LAYOUT_STORAGE_VERSION - 1 }, + }); + + await useWorkspaceStore.getState().openWorkspace("w1"); + + expect(useShellLayoutStore.getState().workingLayout).toEqual(before); + expect(useWorkspaceStore.getState().status).not.toBe("error"); + }); + + it("openWorkspace skips applying a layout_snapshot with no version stamp at all", async () => { + const layout = createDefaultShellLayout(); + layout.regions.header.visible = false; + const before = useShellLayoutStore.getState().workingLayout; + vi.mocked(workspaceClient.getWorkspace).mockResolvedValue({ + ...WORKSPACE, + layout_snapshot: layout, + metadata: {}, + }); + + await useWorkspaceStore.getState().openWorkspace("w1"); + + expect(useShellLayoutStore.getState().workingLayout).toEqual(before); + expect(useWorkspaceStore.getState().status).not.toBe("error"); + }); + + it("openWorkspace discards a stale response once a newer call has resolved", async () => { + let resolveStale: (workspace: Workspace) => void = () => {}; + const stale = new Promise((resolve) => { + resolveStale = resolve; + }); + vi.mocked(workspaceClient.getWorkspace).mockImplementationOnce(() => stale); + vi.mocked(workspaceClient.getWorkspace).mockImplementationOnce(() => + Promise.resolve({ ...WORKSPACE, id: "w2", name: "Second" }), + ); + + // Simulates an auto-open (e.g. restoring the last-used workspace on + // mount) racing a manual selection the user makes before it resolves. + const staleCall = useWorkspaceStore.getState().openWorkspace("w1"); + const freshCall = useWorkspaceStore.getState().openWorkspace("w2"); + await freshCall; + resolveStale({ ...WORKSPACE, id: "w1" }); + await staleCall; + + expect(useWorkspaceStore.getState().activeWorkspaceId).toBe("w2"); + }); + + it("close resets to the app's hardcoded default layout and re-points activeProfileId at the first profile", () => { + const profileLayout = createDefaultShellLayout(); + profileLayout.regions.header.visible = false; + const defaultLayout = createDefaultShellLayout(); + defaultLayout.regions.header.visible = true; + useShellLayoutStore.setState({ + profiles: [ + { id: "p1", name: "Default", layout: structuredClone(defaultLayout) }, + { id: "p2", name: "Custom", layout: structuredClone(profileLayout) }, + ], + activeProfileId: "p2", + workingLayout: structuredClone(profileLayout), + defaultLayout: structuredClone(defaultLayout), + }); + useWorkspaceStore.setState({ activeWorkspaceId: "w1", activeWorkspace: WORKSPACE }); + // Simulate the workspace's own layout_snapshot having been applied on open. + useShellLayoutStore.getState().setLayout((prev) => { + prev.regions.bottom.visible = false; + return prev; + }); + + useWorkspaceStore.getState().close(); + + expect(useShellLayoutStore.getState().workingLayout).toEqual(defaultLayout); + expect(useShellLayoutStore.getState().activeProfileId).toBe("p1"); + // The "Custom" profile's own saved snapshot is untouched. + expect( + useShellLayoutStore.getState().profiles.find((p) => p.id === "p2")!.layout, + ).toEqual(profileLayout); + }); +}); diff --git a/packages/framework-core-ui/src/stores/workspaceStore.ts b/packages/framework-core-ui/src/stores/workspaceStore.ts new file mode 100644 index 0000000..24f0946 --- /dev/null +++ b/packages/framework-core-ui/src/stores/workspaceStore.ts @@ -0,0 +1,361 @@ +import { create } from "zustand"; + +import { + createWorkspace as createWorkspaceRequest, + deleteWorkspace as deleteWorkspaceRequest, + getWorkspace, + listWorkspaces, + updateWorkspace, +} from "../workspaceClient"; +import type { Workspace, WorkspaceSummary } from "../workspaceClient"; +import { SHELL_LAYOUT_STORAGE_VERSION } from "../shellTypes"; +import { useShellLayoutStore } from "./shellStore"; + +/** + * `metadata` key under which {@link SHELL_LAYOUT_STORAGE_VERSION} is stamped + * when saving a workspace's `layout_snapshot`, so it can be compared against + * the running app's version before being applied on open. + */ +const SHELL_LAYOUT_VERSION_METADATA_KEY = "shellLayoutVersion"; + +/** `localStorage` key under which the active workspace id is persisted. */ +export const ACTIVE_WORKSPACE_STORAGE_KEY = "sci-framework:active-workspace-id"; + +/** + * Monotonic counter guarding {@link WorkspaceStore.openWorkspace} against + * out-of-order resolution: an auto-open (e.g. restoring the last-used + * workspace on mount) can race a manual selection the user makes while that + * fetch is still in flight. Each call captures the counter's value at start; + * if a newer call has since started by the time it resolves, its result is + * discarded instead of clobbering the newer selection. + */ +let latestOpenWorkspaceRequestId = 0; + +/** + * Reads the active workspace id last written to `localStorage`, if any. + * + * @returns The stored workspace id, or `null` if none is stored or + * `localStorage` is unavailable. + * @example + * ```ts + * const id = readStoredActiveWorkspaceId(); + * ``` + */ +export function readStoredActiveWorkspaceId(): string | null { + try { + return localStorage.getItem(ACTIVE_WORKSPACE_STORAGE_KEY); + } catch { + return null; + } +} + +function writeStoredActiveWorkspaceId(id: string): void { + try { + localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, id); + } catch { + // localStorage unavailable (e.g. private browsing quota) — degrade silently. + } +} + +/** + * Clears the stored active workspace id from `localStorage`. + * + * @example + * ```ts + * clearStoredActiveWorkspaceId(); + * ``` + */ +export function clearStoredActiveWorkspaceId(): void { + try { + localStorage.removeItem(ACTIVE_WORKSPACE_STORAGE_KEY); + } catch { + // localStorage unavailable — nothing to clear. + } +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + return "Unknown error"; +} + +/** + * Resets the shell's working layout to the app's hardcoded default, + * discarding whatever workspace `layout_snapshot` was applied on top of it by + * {@link openWorkspace}, and points `activeProfileId` back at the first saved + * profile (the same fallback {@link ShellLayoutStore.deleteProfile} uses), + * so the layout-profiles menu never shows a stale profile as active for a + * layout it no longer reflects. + */ +function resetShellLayoutForWorkspaceExit(): void { + const { profiles, resetActiveProfile } = useShellLayoutStore.getState(); + if (profiles[0]) { + useShellLayoutStore.setState({ activeProfileId: profiles[0].id }); + } + resetActiveProfile(); +} + +/** + * Zustand store shape for the backend-persisted workspace CRUD surface. + * + * Only {@link WorkspaceStore.activeWorkspaceId} is persisted client-side (to + * `localStorage`, via {@link readStoredActiveWorkspaceId} / + * {@link clearStoredActiveWorkspaceId} — not the Zustand `persist` + * middleware). `workspaces`, `activeWorkspace`, `status`, and `error` are + * always re-fetched from the backend and must never be treated as durable. + */ +export interface WorkspaceStore { + /** All known workspaces as lightweight summaries. */ + workspaces: WorkspaceSummary[]; + /** Id of the currently active workspace, or `null` if none is open. */ + activeWorkspaceId: string | null; + /** + * Full record of the active workspace, held so `save`/`rename` know what + * to send back. Prefer `activeWorkspaceId` + `workspaces` for rendering. + */ + activeWorkspace: Workspace | null; + /** Whether the store is idle, mid-request, or the last request failed. */ + status: "idle" | "loading" | "error"; + /** Human-readable message for the last failed action, or `null`. */ + error: string | null; + + /** + * Re-fetches {@link WorkspaceStore.workspaces} from the backend. + * + * @returns `true` if the list was refreshed; `false` if the request failed + * (in which case `error` describes the failure). + */ + refreshList: () => Promise; + /** + * Creates a new workspace and makes it active. A successful create also + * writes its id to `localStorage`. + * + * @returns `true` if the workspace was created; `false` if the request + * failed (in which case `error` describes the failure). + */ + createWorkspace: (name: string, goal?: string) => Promise; + /** + * Fetches workspace `id`, makes it active, and — if it has a saved, + * version-compatible `layout_snapshot` — applies it to + * {@link useShellLayoutStore}'s working layout. A workspace with no saved + * snapshot, or one whose snapshot's stamped version doesn't match the + * running app's, leaves the shell layout as-is. + * + * @returns `true` if the workspace was fetched and made active; `false` if + * the request failed (in which case `error` describes the failure). + */ + openWorkspace: (id: string) => Promise; + /** + * Saves the active workspace with the shell's current working layout as + * its `layout_snapshot`. No-op if no workspace is active. + * + * @returns `true` if the save succeeded or there was no active workspace + * to save (a no-op); `false` if the request failed (in which case + * `error` describes the failure). + */ + save: () => Promise; + /** + * Renames the active workspace. No-op if no workspace is active or `name` + * is blank. + * + * @returns `true` if the rename succeeded or was a no-op; `false` if the + * request failed (in which case `error` describes the failure). + */ + rename: (name: string) => Promise; + /** + * Clears the active workspace (store state + `localStorage`) and resets + * the shell's working layout back to its hardcoded default, pointing + * `activeProfileId` at the first saved profile so the layout-profiles menu + * doesn't keep showing a stale profile as active. Also resets `status` and + * `error` to their idle defaults. + */ + close: () => void; + /** + * Deletes workspace `id`. If it was the active workspace, behaves like + * {@link WorkspaceStore.close} afterward. + * + * @returns `true` if the delete succeeded; `false` if the request failed + * (in which case `error` describes the failure). + */ + deleteWorkspace: (id: string) => Promise; +} + +/** + * Zustand store for backend-persisted workspaces. See {@link WorkspaceStore}. + * + * @example + * ```ts + * const { openWorkspace, save } = useWorkspaceStore(); + * await openWorkspace("w1"); + * await save(); + * ``` + */ +export const useWorkspaceStore = create()((set, get) => ({ + workspaces: [], + activeWorkspaceId: null, + activeWorkspace: null, + status: "idle", + error: null, + + refreshList: async () => { + set({ status: "loading", error: null }); + try { + const workspaces = await listWorkspaces(); + set({ workspaces, status: "idle", error: null }); + return true; + } catch (error) { + set({ status: "error", error: describeError(error) }); + return false; + } + }, + + createWorkspace: async (name, goal) => { + set({ status: "loading", error: null }); + try { + const workspace = await createWorkspaceRequest({ name, goal }); + writeStoredActiveWorkspaceId(workspace.id); + set((state) => ({ + workspaces: [ + { + id: workspace.id, + name: workspace.name, + created_at: workspace.created_at, + updated_at: workspace.updated_at, + }, + ...state.workspaces, + ], + activeWorkspaceId: workspace.id, + activeWorkspace: workspace, + status: "idle", + error: null, + })); + return true; + } catch (error) { + set({ status: "error", error: describeError(error) }); + return false; + } + }, + + openWorkspace: async (id) => { + const requestId = ++latestOpenWorkspaceRequestId; + set({ status: "loading", error: null }); + try { + const workspace = await getWorkspace(id); + // A newer openWorkspace call started while this one was in flight + // (e.g. a manual selection racing an auto-open) — that call owns the + // store now, so drop this stale response instead of overwriting it. + if (requestId !== latestOpenWorkspaceRequestId) return true; + writeStoredActiveWorkspaceId(workspace.id); + set({ + activeWorkspaceId: workspace.id, + activeWorkspace: workspace, + status: "idle", + error: null, + }); + const snapshot = workspace.layout_snapshot; + // layout_snapshot and the shell's own persisted layout are two + // independent channels for the same ShellLayout shape; only apply the + // snapshot when it was stamped by a compatible app version, so we + // never splice an incompatible/foreign snapshot into the live layout. + const snapshotVersion = workspace.metadata[SHELL_LAYOUT_VERSION_METADATA_KEY]; + if (snapshot && snapshotVersion === SHELL_LAYOUT_STORAGE_VERSION) { + useShellLayoutStore.getState().setLayout(() => structuredClone(snapshot)); + } + return true; + } catch (error) { + if (requestId !== latestOpenWorkspaceRequestId) return false; + set({ status: "error", error: describeError(error) }); + return false; + } + }, + + save: async () => { + const { activeWorkspace } = get(); + if (!activeWorkspace) return true; + set({ status: "loading", error: null }); + try { + const layout = useShellLayoutStore.getState().workingLayout; + const record: Workspace = { + ...activeWorkspace, + layout_snapshot: layout, + metadata: { + ...activeWorkspace.metadata, + [SHELL_LAYOUT_VERSION_METADATA_KEY]: SHELL_LAYOUT_STORAGE_VERSION, + }, + }; + const saved = await updateWorkspace(activeWorkspace.id, record); + set((state) => ({ + activeWorkspace: saved, + workspaces: state.workspaces.map((w) => + w.id === saved.id + ? { ...w, name: saved.name, updated_at: saved.updated_at } + : w, + ), + status: "idle", + error: null, + })); + return true; + } catch (error) { + set({ status: "error", error: describeError(error) }); + return false; + } + }, + + rename: async (name) => { + const { activeWorkspace } = get(); + const trimmed = name.trim(); + if (!activeWorkspace || !trimmed) return true; + set({ status: "loading", error: null }); + try { + const record: Workspace = { ...activeWorkspace, name: trimmed }; + const saved = await updateWorkspace(activeWorkspace.id, record); + set((state) => ({ + activeWorkspace: saved, + workspaces: state.workspaces.map((w) => + w.id === saved.id + ? { ...w, name: saved.name, updated_at: saved.updated_at } + : w, + ), + status: "idle", + error: null, + })); + return true; + } catch (error) { + set({ status: "error", error: describeError(error) }); + return false; + } + }, + + close: () => { + clearStoredActiveWorkspaceId(); + set({ + activeWorkspaceId: null, + activeWorkspace: null, + status: "idle", + error: null, + }); + resetShellLayoutForWorkspaceExit(); + }, + + deleteWorkspace: async (id) => { + set({ status: "loading", error: null }); + try { + await deleteWorkspaceRequest(id); + const closingActive = get().activeWorkspaceId === id; + set((state) => ({ + workspaces: state.workspaces.filter((w) => w.id !== id), + activeWorkspaceId: closingActive ? null : state.activeWorkspaceId, + activeWorkspace: closingActive ? null : state.activeWorkspace, + status: "idle", + error: null, + })); + if (closingActive) { + clearStoredActiveWorkspaceId(); + resetShellLayoutForWorkspaceExit(); + } + return true; + } catch (error) { + set({ status: "error", error: describeError(error) }); + return false; + } + }, +})); diff --git a/packages/framework-core-ui/src/workspaceClient.test.ts b/packages/framework-core-ui/src/workspaceClient.test.ts new file mode 100644 index 0000000..8fe103b --- /dev/null +++ b/packages/framework-core-ui/src/workspaceClient.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildWorkspaceApiUrl, + createWorkspace, + deleteWorkspace, + getWorkspace, + listWorkspaces, + updateWorkspace, + WorkspaceApiError, +} from "./workspaceClient"; +import type { Workspace } from "./workspaceClient"; + +const LOCATION = { protocol: "http:", host: "localhost:5173" }; + +function mockFetchOnce(status: number, body: unknown): void { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: "", + json: () => Promise.resolve(body), + }), + ); +} + +const WORKSPACE: Workspace = { + id: "w1", + name: "Drone survey", + created_at: "2026-08-26T00:00:00Z", + updated_at: "2026-08-26T00:00:00Z", + goal: null, + layout_snapshot: null, + metadata: {}, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("buildWorkspaceApiUrl", () => { + it("builds an http URL from a non-https location", () => { + expect(buildWorkspaceApiUrl({ protocol: "http:", host: "localhost:5173" })).toBe( + "http://localhost:5173/api/workspaces", + ); + }); + + it("builds an https URL from an https location", () => { + expect(buildWorkspaceApiUrl({ protocol: "https:", host: "app.example.com" })).toBe( + "https://app.example.com/api/workspaces", + ); + }); +}); + +describe("listWorkspaces", () => { + it("returns the parsed summary list on success", async () => { + mockFetchOnce(200, [WORKSPACE]); + + const result = await listWorkspaces(LOCATION); + + expect(result).toEqual([WORKSPACE]); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + "http://localhost:5173/api/workspaces", + ); + }); + + it("throws WorkspaceApiError with the parsed detail on failure", async () => { + mockFetchOnce(500, { detail: "boom" }); + + await expect(listWorkspaces(LOCATION)).rejects.toMatchObject({ + name: "WorkspaceApiError", + status: 500, + message: "boom", + }); + }); + + it("falls back to statusText when the error body isn't JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: () => Promise.reject(new Error("not json")), + }), + ); + + await expect(listWorkspaces(LOCATION)).rejects.toMatchObject({ + status: 502, + message: "Bad Gateway", + }); + }); +}); + +describe("createWorkspace", () => { + it("POSTs name and goal and returns the created workspace", async () => { + mockFetchOnce(201, WORKSPACE); + + const result = await createWorkspace({ name: "Drone survey" }, LOCATION); + + expect(result).toEqual(WORKSPACE); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + "http://localhost:5173/api/workspaces", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Drone survey" }), + }), + ); + }); +}); + +describe("getWorkspace", () => { + it("returns the workspace on success", async () => { + mockFetchOnce(200, WORKSPACE); + + const result = await getWorkspace("w1", LOCATION); + + expect(result).toEqual(WORKSPACE); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + "http://localhost:5173/api/workspaces/w1", + ); + }); + + it("throws WorkspaceApiError with status 404 when missing", async () => { + mockFetchOnce(404, { detail: "Workspace 'w1' not found" }); + + await expect(getWorkspace("w1", LOCATION)).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe("updateWorkspace", () => { + it("PUTs the full record and returns the saved workspace", async () => { + mockFetchOnce(200, WORKSPACE); + + const result = await updateWorkspace("w1", WORKSPACE, LOCATION); + + expect(result).toEqual(WORKSPACE); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + "http://localhost:5173/api/workspaces/w1", + expect.objectContaining({ method: "PUT", body: JSON.stringify(WORKSPACE) }), + ); + }); +}); + +describe("deleteWorkspace", () => { + it("DELETEs and resolves on success", async () => { + mockFetchOnce(204, undefined); + + await expect(deleteWorkspace("w1", LOCATION)).resolves.toBeUndefined(); + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + "http://localhost:5173/api/workspaces/w1", + { + method: "DELETE", + }, + ); + }); + + it("throws WorkspaceApiError on failure", async () => { + mockFetchOnce(404, { detail: "not found" }); + + await expect(deleteWorkspace("w1", LOCATION)).rejects.toBeInstanceOf( + WorkspaceApiError, + ); + }); +}); diff --git a/packages/framework-core-ui/src/workspaceClient.ts b/packages/framework-core-ui/src/workspaceClient.ts new file mode 100644 index 0000000..1cc50a6 --- /dev/null +++ b/packages/framework-core-ui/src/workspaceClient.ts @@ -0,0 +1,204 @@ +import type { ShellLayout } from "./shellTypes"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** + * Lightweight workspace listing entry, mirroring the backend's + * `WorkspaceSummary` Pydantic model. + */ +export interface WorkspaceSummary { + /** Server-generated UUID4, immutable for the workspace's lifetime. */ + id: string; + /** User-editable display name. */ + name: string; + /** ISO 8601 timestamp the workspace was created. */ + created_at: string; + /** ISO 8601 timestamp the workspace was last saved. */ + updated_at: string; +} + +/** + * Full workspace record, mirroring the backend's `Workspace` Pydantic model. + */ +export interface Workspace extends WorkspaceSummary { + /** Free-text description of the workspace's goal, or `null` if unset. */ + goal: string | null; + /** Shell layout captured on last save, or `null` if never saved with one. */ + layout_snapshot: ShellLayout | null; + /** Escape-hatch bag for future scenario/constraint references. */ + metadata: Record; +} + +interface LocationLike { + /** e.g. `"http:"` or `"https:"`. */ + protocol: string; + /** Host including port, e.g. `"localhost:5173"`. */ + host: string; +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +/** + * Thrown by every `workspaceClient` function on a non-2xx HTTP response. + */ +export class WorkspaceApiError extends Error { + /** HTTP status code of the failed response. */ + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "WorkspaceApiError"; + this.status = status; + } +} + +async function throwIfNotOk(response: Response): Promise { + if (response.ok) return; + let detail = response.statusText; + try { + const body: unknown = await response.json(); + if ( + body && + typeof body === "object" && + typeof (body as { detail?: unknown }).detail === "string" + ) { + detail = (body as { detail: string }).detail; + } + } catch { + // Response body wasn't JSON (or was empty) — fall back to statusText. + } + throw new WorkspaceApiError(response.status, detail); +} + +// ─── URL building ───────────────────────────────────────────────────────────── + +/** + * Builds the base `/api/workspaces` REST API URL from a browser location-like object. + * + * @param locationLike Current location containing protocol and host. + * @returns Absolute `http://` or `https://` base URL for the workspaces API. + * @example + * ```ts + * const url = buildWorkspaceApiUrl(window.location); + * // http://localhost:5173/api/workspaces + * ``` + */ +export function buildWorkspaceApiUrl(locationLike: LocationLike): string { + const scheme = locationLike.protocol === "https:" ? "https" : "http"; + return `${scheme}://${locationLike.host}/api/workspaces`; +} + +// ─── CRUD functions ───────────────────────────────────────────────────────────── + +/** + * Lists all workspaces as lightweight summaries. + * + * @param locationLike Current location, defaults to `window.location`. + * @returns Summaries sorted by `updated_at` descending (server-side sort). + * @throws {WorkspaceApiError} On a non-2xx response. + * @example + * ```ts + * const workspaces = await listWorkspaces(); + * ``` + */ +export async function listWorkspaces( + locationLike: LocationLike = window.location, +): Promise { + const response = await fetch(buildWorkspaceApiUrl(locationLike)); + await throwIfNotOk(response); + return response.json() as Promise; +} + +/** + * Creates a new workspace. + * + * @param input Name and optional goal for the new workspace. + * @param locationLike Current location, defaults to `window.location`. + * @returns The newly created `Workspace`, with generated `id` and timestamps. + * @throws {WorkspaceApiError} On a non-2xx response. + * @example + * ```ts + * const workspace = await createWorkspace({ name: "Drone survey" }); + * ``` + */ +export async function createWorkspace( + input: { name: string; goal?: string }, + locationLike: LocationLike = window.location, +): Promise { + const response = await fetch(buildWorkspaceApiUrl(locationLike), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + await throwIfNotOk(response); + return response.json() as Promise; +} + +/** + * Fetches one workspace by id. + * + * @param id The workspace's `id`. + * @param locationLike Current location, defaults to `window.location`. + * @returns The full `Workspace` record. + * @throws {WorkspaceApiError} With `status: 404` if no such workspace exists. + * @example + * ```ts + * const workspace = await getWorkspace("w1"); + * ``` + */ +export async function getWorkspace( + id: string, + locationLike: LocationLike = window.location, +): Promise { + const response = await fetch(`${buildWorkspaceApiUrl(locationLike)}/${id}`); + await throwIfNotOk(response); + return response.json() as Promise; +} + +/** + * Saves changes to an existing workspace. + * + * @param id The workspace's `id`. + * @param workspace The full record to save. + * @param locationLike Current location, defaults to `window.location`. + * @returns The saved `Workspace`, with `updated_at` refreshed by the server. + * @throws {WorkspaceApiError} With `status: 404` if no such workspace exists. + * @example + * ```ts + * const saved = await updateWorkspace("w1", workspace); + * ``` + */ +export async function updateWorkspace( + id: string, + workspace: Workspace, + locationLike: LocationLike = window.location, +): Promise { + const response = await fetch(`${buildWorkspaceApiUrl(locationLike)}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(workspace), + }); + await throwIfNotOk(response); + return response.json() as Promise; +} + +/** + * Deletes a workspace. + * + * @param id The workspace's `id`. + * @param locationLike Current location, defaults to `window.location`. + * @throws {WorkspaceApiError} With `status: 404` if no such workspace exists. + * @example + * ```ts + * await deleteWorkspace("w1"); + * ``` + */ +export async function deleteWorkspace( + id: string, + locationLike: LocationLike = window.location, +): Promise { + const response = await fetch(`${buildWorkspaceApiUrl(locationLike)}/${id}`, { + method: "DELETE", + }); + await throwIfNotOk(response); +} diff --git a/pypackages/framework-core/src/sci_framework_core/__init__.py b/pypackages/framework-core/src/sci_framework_core/__init__.py index a53abfb..c82f7b0 100644 --- a/pypackages/framework-core/src/sci_framework_core/__init__.py +++ b/pypackages/framework-core/src/sci_framework_core/__init__.py @@ -13,7 +13,7 @@ def create_app(lifespan: Any = None) -> FastAPI: """Create and return a configured FastAPI application. - Mounts the ``/ws`` WebSocket endpoint backed by a shared ``EventBus``. + Mounts the ``/api/ws`` WebSocket endpoint backed by a shared ``EventBus``. The application's ``EventBus`` is accessible via ``app.state.bus``. Args: @@ -25,7 +25,7 @@ def create_app(lifespan: Any = None) -> FastAPI: Returns: Configured ``FastAPI`` application with a shared ``EventBus`` and - websocket bridge mounted at ``/ws``. + websocket bridge mounted at ``/api/ws``. Example:: diff --git a/pypackages/framework-core/src/sci_framework_core/ai_layout.py b/pypackages/framework-core/src/sci_framework_core/ai_layout.py index 562d2eb..cfbb5c0 100644 --- a/pypackages/framework-core/src/sci_framework_core/ai_layout.py +++ b/pypackages/framework-core/src/sci_framework_core/ai_layout.py @@ -1,7 +1,7 @@ """OpenRouter client, prompt builder, and layout validator for AI layout generation. Entry point for consumers: ``mount_ai_routes(app)`` — call once after ``create_app()`` -to attach the ``POST /ai/layout`` endpoint to a FastAPI application. +to attach the ``POST /api/ai/layout`` endpoint to a FastAPI application. """ from __future__ import annotations @@ -13,9 +13,11 @@ from typing import Any import httpx -from fastapi import FastAPI, HTTPException +from fastapi import APIRouter, FastAPI, HTTPException from pydantic import BaseModel +from .constants import API_PREFIX + # ─── Constants ──────────────────────────────────────────────────────────────── OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" @@ -522,7 +524,7 @@ def validate_layout( class LayoutRequest(BaseModel): - """Request body for ``POST /ai/layout``. + """Request body for ``POST /api/ai/layout``. Attributes: prompt: Natural-language description of the desired dashboard. @@ -553,7 +555,7 @@ class LayoutRequest(BaseModel): class LayoutResponse(BaseModel): - """Response body for ``POST /ai/layout``. + """Response body for ``POST /api/ai/layout``. Attributes: layout: Validated ``ShellLayout`` dict generated by the AI. @@ -587,11 +589,16 @@ def mount_ai_routes(app: FastAPI) -> None: conversation. If the retry also fails validation, a ``422`` response is returned with the validation errors and the AI's explanation. + Defines a ``POST /ai/layout`` route on a fresh :class:`~fastapi.APIRouter` + and includes it on ``app`` under :data:`API_PREFIX`, so the endpoint ends + up at ``/api/ai/layout``. + Args: app: FastAPI application instance to attach routes to. """ + router = APIRouter() - @app.post("/ai/layout", response_model=LayoutResponse) + @router.post("/ai/layout", response_model=LayoutResponse) async def generate_layout(request: LayoutRequest) -> LayoutResponse: """Generate a validated ShellLayout from a natural-language prompt. @@ -674,3 +681,5 @@ async def generate_layout(request: LayoutRequest) -> LayoutResponse: return LayoutResponse( layout=layout, explanation=explanation, suggested_params=suggested_params ) + + app.include_router(router, prefix=API_PREFIX) diff --git a/pypackages/framework-core/src/sci_framework_core/constants.py b/pypackages/framework-core/src/sci_framework_core/constants.py new file mode 100644 index 0000000..aedfd35 --- /dev/null +++ b/pypackages/framework-core/src/sci_framework_core/constants.py @@ -0,0 +1,11 @@ +"""Shared constants for the framework's backend feature routers.""" + +from __future__ import annotations + +API_PREFIX = "/api" +"""URL prefix every feature router is mounted under via ``app.include_router``. + +Keeping this in one place means every backend route (``/ws``, ``/ai/layout``, +``/workspaces``, ...) ends up under a single ``/api`` prefix, so a frontend +dev server needs only one proxy rule to reach all of them. +""" diff --git a/pypackages/framework-core/src/sci_framework_core/workspace.py b/pypackages/framework-core/src/sci_framework_core/workspace.py index 7a735b9..a488954 100644 --- a/pypackages/framework-core/src/sci_framework_core/workspace.py +++ b/pypackages/framework-core/src/sci_framework_core/workspace.py @@ -1,7 +1,7 @@ """Server-side workspace persistence — one JSON file per workspace on local disk. Entry point for consumers: ``mount_workspace_routes(app, app_name)`` — call once -after ``create_app()`` to attach the ``/workspaces`` CRUD endpoints to a FastAPI +after ``create_app()`` to attach the ``/api/workspaces`` CRUD endpoints to a FastAPI application. """ @@ -12,9 +12,11 @@ from pathlib import Path from typing import Any -from fastapi import FastAPI, HTTPException, status +from fastapi import APIRouter, FastAPI, HTTPException, status from pydantic import BaseModel +from .constants import API_PREFIX + # ─── Data model ─────────────────────────────────────────────────────────────── @@ -239,6 +241,10 @@ def mount_workspace_routes( app = create_app(lifespan=lifespan) mount_workspace_routes(app, app_name="drone") + Defines the ``/workspaces`` CRUD routes on a fresh + :class:`~fastapi.APIRouter` and includes it on ``app`` under + :data:`API_PREFIX`, so the endpoints end up at ``/api/workspaces``. + Args: app: FastAPI application instance to attach routes to. app_name: Application identifier used to derive the default storage @@ -249,8 +255,9 @@ def mount_workspace_routes( derived from ``app_name``. Primarily for tests. """ store = WorkspaceStore(workspace_dir or default_workspace_dir(app_name)) + router = APIRouter() - @app.post( + @router.post( "/workspaces", response_model=Workspace, status_code=status.HTTP_201_CREATED, @@ -267,7 +274,7 @@ async def create_workspace(request: CreateWorkspaceRequest) -> Workspace: """ return store.create(name=request.name, goal=request.goal) - @app.get("/workspaces", response_model=list[WorkspaceSummary]) + @router.get("/workspaces", response_model=list[WorkspaceSummary]) async def list_workspaces() -> list[WorkspaceSummary]: """List all workspaces as lightweight summaries. @@ -276,7 +283,7 @@ async def list_workspaces() -> list[WorkspaceSummary]: """ return store.list_summaries() - @app.get("/workspaces/{workspace_id}", response_model=Workspace) + @router.get("/workspaces/{workspace_id}", response_model=Workspace) async def get_workspace(workspace_id: str) -> Workspace: """Fetch one workspace by id. @@ -296,7 +303,7 @@ async def get_workspace(workspace_id: str) -> Workspace: status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc - @app.put("/workspaces/{workspace_id}", response_model=Workspace) + @router.put("/workspaces/{workspace_id}", response_model=Workspace) async def update_workspace(workspace_id: str, workspace: Workspace) -> Workspace: """Save changes to an existing workspace. @@ -318,7 +325,7 @@ async def update_workspace(workspace_id: str, workspace: Workspace) -> Workspace status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc - @app.delete("/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) + @router.delete("/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace(workspace_id: str) -> None: """Delete a workspace. @@ -334,3 +341,5 @@ async def delete_workspace(workspace_id: str) -> None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) ) from exc + + app.include_router(router, prefix=API_PREFIX) diff --git a/pypackages/framework-core/src/sci_framework_core/ws_bridge.py b/pypackages/framework-core/src/sci_framework_core/ws_bridge.py index 7609600..3a0edb1 100644 --- a/pypackages/framework-core/src/sci_framework_core/ws_bridge.py +++ b/pypackages/framework-core/src/sci_framework_core/ws_bridge.py @@ -2,9 +2,10 @@ from typing import Any -from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, FastAPI, WebSocket, WebSocketDisconnect from .bus import BaseEvent, EventBus, EventHandler +from .constants import API_PREFIX class _ClientPublishEvent(BaseEvent): @@ -44,7 +45,11 @@ def _event_to_wire_message(channel: str, message: BaseEvent) -> dict[str, Any]: def _mount_ws_bridge(app: FastAPI, bus: EventBus) -> None: - """Mount a multiplexed ``/ws`` WebSocket endpoint that bridges to ``bus``. + """Mount a multiplexed ``/api/ws`` WebSocket endpoint that bridges to ``bus``. + + Defines a ``ws`` route on a fresh :class:`~fastapi.APIRouter` and includes + it on ``app`` under :data:`API_PREFIX`, so the endpoint ends up at + ``/api/ws``. Each connected client maintains its own subscription map. All channel traffic for a single browser tab is multiplexed over one connection. @@ -64,7 +69,9 @@ def _mount_ws_bridge(app: FastAPI, bus: EventBus) -> None: awaited (see ``EventBus.subscribe``). """ - @app.websocket("/ws") + router = APIRouter() + + @router.websocket("/ws") async def websocket_bridge(websocket: WebSocket) -> None: await websocket.accept() @@ -124,3 +131,5 @@ async def _handler(channel: str, message: BaseEvent) -> None: finally: for ch, handler in list(subscriptions.items()): bus.unsubscribe(ch, handler) + + app.include_router(router, prefix=API_PREFIX) diff --git a/pypackages/framework-core/tests/test_ai_endpoints.py b/pypackages/framework-core/tests/test_ai_endpoints.py index 6c9effb..da05e4d 100644 --- a/pypackages/framework-core/tests/test_ai_endpoints.py +++ b/pypackages/framework-core/tests/test_ai_endpoints.py @@ -80,7 +80,7 @@ def test_layout_endpoint_returns_layout( return_value=_VALID_AI_RESPONSE, ): response = client.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "Build a sine wave dashboard", "history": [], @@ -108,7 +108,7 @@ def test_layout_endpoint_rejects_unknown_widget( return_value=_INVALID_WIDGET_RESPONSE, ): response = client.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "Build a dashboard", "history": [], @@ -141,7 +141,7 @@ def test_layout_endpoint_returns_suggested_params_with_snapshot( return_value=diagnosis_response, ) as mock_call: response = client.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "What's wrong with this run?", "history": [], @@ -181,7 +181,7 @@ def test_layout_endpoint_without_context_has_no_suggested_params( return_value=_VALID_AI_RESPONSE, ) as mock_call: response = client.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "Build a sine wave dashboard", "history": [], @@ -218,7 +218,7 @@ def test_layout_endpoint_combined_diagnosis_and_layout_change( return_value=combined_response, ): response = client.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "Fix it and show me the margin", "history": [], @@ -241,7 +241,7 @@ def test_layout_endpoint_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> Non mount_ai_routes(app) with TestClient(app) as c: response = c.post( - "/ai/layout", + "/api/ai/layout", json={ "prompt": "Build a dashboard", "history": [], diff --git a/pypackages/framework-core/tests/test_workspace.py b/pypackages/framework-core/tests/test_workspace.py index 7b7a0c7..131cf0e 100644 --- a/pypackages/framework-core/tests/test_workspace.py +++ b/pypackages/framework-core/tests/test_workspace.py @@ -135,7 +135,7 @@ def client(tmp_path: Path) -> TestClient: def test_post_creates_workspace(client: TestClient) -> None: response = client.post( - "/workspaces", + "/api/workspaces", json={"name": "My Workspace", "goal": "Test goal"}, ) assert response.status_code == 201 @@ -146,33 +146,33 @@ def test_post_creates_workspace(client: TestClient) -> None: def test_get_list_returns_summaries_sorted_desc(client: TestClient) -> None: - first = client.post("/workspaces", json={"name": "First"}).json() - client.post("/workspaces", json={"name": "Second"}).json() - client.put(f"/workspaces/{first['id']}", json=first) + first = client.post("/api/workspaces", json={"name": "First"}).json() + client.post("/api/workspaces", json={"name": "Second"}).json() + client.put(f"/api/workspaces/{first['id']}", json=first) - response = client.get("/workspaces") + response = client.get("/api/workspaces") assert response.status_code == 200 names = [w["name"] for w in response.json()] assert names == ["First", "Second"] def test_get_by_id_returns_created(client: TestClient) -> None: - created = client.post("/workspaces", json={"name": "My Workspace"}).json() - response = client.get(f"/workspaces/{created['id']}") + created = client.post("/api/workspaces", json={"name": "My Workspace"}).json() + response = client.get(f"/api/workspaces/{created['id']}") assert response.status_code == 200 assert response.json() == created def test_get_unknown_id_returns_404(client: TestClient) -> None: - response = client.get("/workspaces/does-not-exist") + response = client.get("/api/workspaces/does-not-exist") assert response.status_code == 404 def test_put_updates_and_refreshes_updated_at(client: TestClient) -> None: - created = client.post("/workspaces", json={"name": "Old Name"}).json() + created = client.post("/api/workspaces", json={"name": "Old Name"}).json() payload = {**created, "id": "ignored-mismatch", "name": "New Name"} - response = client.put(f"/workspaces/{created['id']}", json=payload) + response = client.put(f"/api/workspaces/{created['id']}", json=payload) assert response.status_code == 200 body = response.json() @@ -182,25 +182,25 @@ def test_put_updates_and_refreshes_updated_at(client: TestClient) -> None: def test_put_unknown_id_returns_404(client: TestClient) -> None: - created = client.post("/workspaces", json={"name": "X"}).json() - response = client.put("/workspaces/does-not-exist", json=created) + created = client.post("/api/workspaces", json={"name": "X"}).json() + response = client.put("/api/workspaces/does-not-exist", json=created) assert response.status_code == 404 def test_delete_removes_and_subsequent_get_404s(client: TestClient) -> None: - created = client.post("/workspaces", json={"name": "To Delete"}).json() - delete_response = client.delete(f"/workspaces/{created['id']}") + created = client.post("/api/workspaces", json={"name": "To Delete"}).json() + delete_response = client.delete(f"/api/workspaces/{created['id']}") assert delete_response.status_code == 204 - assert client.get(f"/workspaces/{created['id']}").status_code == 404 + assert client.get(f"/api/workspaces/{created['id']}").status_code == 404 def test_delete_unknown_id_returns_404(client: TestClient) -> None: - response = client.delete("/workspaces/does-not-exist") + response = client.delete("/api/workspaces/does-not-exist") assert response.status_code == 404 def test_malformed_body_returns_422(client: TestClient) -> None: - response = client.post("/workspaces", json={"goal": "missing name"}) + response = client.post("/api/workspaces", json={"goal": "missing name"}) assert response.status_code == 422 diff --git a/pypackages/framework-core/tests/test_ws_bridge.py b/pypackages/framework-core/tests/test_ws_bridge.py index fe7703e..058eb34 100644 --- a/pypackages/framework-core/tests/test_ws_bridge.py +++ b/pypackages/framework-core/tests/test_ws_bridge.py @@ -32,7 +32,7 @@ def test_websocket_subscribe_replays_last_message() -> None: with TestClient(app) as client: # Publish the seed message through the WebSocket itself so it lands in # the same event loop that the app is running on. - with client.websocket_connect("/ws") as seed_ws: + with client.websocket_connect("/api/ws") as seed_ws: seed_ws.send_json( { "action": "publish", @@ -42,7 +42,7 @@ def test_websocket_subscribe_replays_last_message() -> None: ) # A fresh connection subscribes and should receive the replayed message. - with client.websocket_connect("/ws") as ws: + with client.websocket_connect("/api/ws") as ws: ws.send_json({"action": "subscribe", "channel": "sensor/temperature"}) received = ws.receive_json() @@ -59,13 +59,13 @@ def test_websocket_subscribe_and_receive_live_message() -> None: app = create_app() with TestClient(app) as client: - with client.websocket_connect("/ws") as subscriber: + with client.websocket_connect("/api/ws") as subscriber: subscriber.send_json( {"action": "subscribe", "channel": "sensor/temperature"} ) # Publish from a second connection on the same app. - with client.websocket_connect("/ws") as publisher: + with client.websocket_connect("/api/ws") as publisher: publisher.send_json( { "action": "publish", @@ -85,11 +85,11 @@ def test_websocket_unsubscribe_stops_delivery() -> None: app = create_app() with TestClient(app) as client: - with client.websocket_connect("/ws") as ws: + with client.websocket_connect("/api/ws") as ws: ws.send_json({"action": "subscribe", "channel": "sensor/temperature"}) ws.send_json({"action": "unsubscribe", "channel": "sensor/temperature"}) - with client.websocket_connect("/ws") as publisher: + with client.websocket_connect("/api/ws") as publisher: publisher.send_json( { "action": "publish", @@ -113,7 +113,7 @@ def test_websocket_invalid_action_returns_error() -> None: app = create_app() with TestClient(app) as client: - with client.websocket_connect("/ws") as ws: + with client.websocket_connect("/api/ws") as ws: ws.send_json({"action": "unknown", "channel": "sensor/temperature"}) received = ws.receive_json() @@ -124,7 +124,7 @@ def test_websocket_missing_channel_returns_error() -> None: app = create_app() with TestClient(app) as client: - with client.websocket_connect("/ws") as ws: + with client.websocket_connect("/api/ws") as ws: ws.send_json({"action": "subscribe"}) received = ws.receive_json()