diff --git a/CLAUDE.md b/CLAUDE.md index cba08a46..f7dc85ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,9 @@ own library (so a workspace name shadows a shared one), is tagged `origin: common` by `GET /api/assets`, and is written to only when a call says so (`?shared=true` on uploads, `"shared": true` on keep, `shared=True` over MCP). Reserved names: `workflows`, `prompts`, `assets`, `outputs`, `exports`, -`common`. +`common`. The web UI is organised by workspace, with a sidebar listing every +workspace on the server (`ui/src/lib/Sidebar.svelte`) and the selected one +named in the hash (`#/ws//...`). The web UI has a page for it: `ui/src/lib/pages/AssetsPage.svelte` (#165) reads `GET /api/assets` and shows the library the way the gallery shows diff --git a/docs/SERVER.md b/docs/SERVER.md index 2794131a..f357d87c 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -28,14 +28,31 @@ load entirely. ## The pages -- **Workflows** — every workflow on the search path (the workspace's own - `--workflow-dir` first, then any `--examples-dir`, read-only), as cards - with descriptions, output kinds, and variable counts. Folders one level - deep become sections. Click through to a run form generated from the - workflow's variables, with the raw JSON alongside. When the server holds - more than one workspace, a picker here chooses which one's workflows are - listed and where a save lands. -- **Prompts** — the prompt library under `--prompt-dir` (default: discovered +The UI is organised by workspace. A sidebar lists every workspace on the +server; the selected one opens into **Overview, Workflows, Jobs, Gallery, +Assets, Editor**, and the hash carries the workspace (`#/ws/studio/gallery`), +so a link names where it points and an old `#/gallery` bookmark redirects to +the last workspace you were in. Below the workspaces, **Shared** holds what +every workspace sees — the prompt library, the `common` asset library and +the read-only example workflows — and **Server** holds Models, Schema and +Status. + +- **Overview** — a glance at the workspace: its recent outputs, its recent + jobs, its workflows (ones with a proof first, plus a filled **New + workflow** button), its asset count, and disk usage. Each panel loads and + fails independently, so a slow gallery does not hold the jobs list back + and a panel's own error shows in place of its content rather than reading + as an empty workspace. **Manage** on this page is where a workspace is + deleted (disabled for `default`); a workspace is created from the sidebar. +- **Workflows** — every workflow on the workspace's own search path + (`--workflow-dir`), as cards with descriptions, output kinds, and variable + counts; the read-only ones from an `--examples-dir` are under Shared → + Examples instead, and opening one's Editor there saves a copy into the + current workspace. Folders one level deep become sections. Click through + to a run form generated from the workflow's variables, with the raw JSON + alongside. +- **Prompts** — under Shared: the prompt library under `--prompt-dir` + (default: discovered the way a CLI run discovers it, then pinned for every job, so the page and `prompt:` resolution always agree), plus the read-only `prompts/` beside each `--examples-dir`, so an example's `prompt:` references resolve: stored @@ -48,9 +65,8 @@ load entirely. argument written as `prompt:name` loads the stored text at run time, and deleting a prompt warns which workflows reference it. - **Jobs** — the queue and full run history (persisted in - `~/.diffusers_helper/jobs.sqlite`), spanning every workspace with a filter - to narrow to one; each job says which workspace it ran in, and keeps it - through a rerun. A running job streams step-by-step + `~/.diffusers_helper/jobs.sqlite`). A workspace's Jobs page lists its own; + Server → Status lists every workspace's with a filter. A running job streams step-by-step progress, per-step denoising ticks, what each step is doing when it is not denoising (loading a model, decoding, saving), and its result files as they land. @@ -110,15 +126,16 @@ load entirely. upgrade it to GitHub HEAD - new model pipelines usually land there before a PyPI release. The idle worker restarts on success so the next job imports the new version; the upgrade is refused while a job runs. -- **Server** — what this server is and how to reach it: device, version, - bind address and LAN addresses, whether a token is required, whether - `/mcp` is mounted (with the `claude mcp add` line to connect to it), the - directories in use, and the workspaces on this server — created and - deleted from here. - **Schema** — the workflow JSON schema the running server validates against, as a browsable tree: the document root plus every definition, with types, required markers, defaults, enums, and descriptions. `$ref` labels jump to their definition; a filter narrows the list. +- **Server → Status** — what this server is and how to reach it: device, + version, bind address and LAN addresses, whether a token is required, + whether `/mcp` is mounted (with the `claude mcp add` line to connect to + it), and the directories in use — and, below, the queue across every + workspace. Workspaces are created from the sidebar and deleted from their + Overview. ## Workspaces @@ -136,11 +153,12 @@ unless one is named. A workspace is a namespace, not a security boundary: the API token is all-or-nothing. See [Workspaces](WORKSPACES.md#several-workspaces-on-one-server). -The Server page lists them, creates them, and deletes them - beside the -directories it resolved and the `claude mcp add` line for connecting an agent -from another machine: +The sidebar lists every workspace, and `+ new` there creates one; a +workspace's own Overview page is where it is deleted. Server → Status shows +the resolved directories and the `claude mcp add` line for connecting an +agent from another machine, plus the queue across every workspace: -![The Server page: address picker, generated claude mcp add line, resolved directories, and the workspace list](img/ui-server-dark.png) +![The Server page before the sidebar: address picker, generated claude mcp add line, resolved directories — the workspace list it shows now lives in the sidebar](img/ui-server-dark.png) ## Jobs API diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md index ed11a20e..13556ab8 100644 --- a/docs/WORKSPACES.md +++ b/docs/WORKSPACES.md @@ -281,10 +281,10 @@ pre-workspace call still means what it meant. | Client | How | | --- | --- | -| Web UI | The workspace picker on the Workflows and Gallery pages. The choice is remembered in `localStorage`, and the Jobs page adds a filter — job history spans every workspace and says which one each job ran in | +| Web UI | The sidebar lists every workspace; the selected one is named in the hash (`#/ws//...`), so a link and a reload both land where they say. The choice is remembered in `localStorage` as a fallback for a route that names none (Shared, Server), and Server → Status adds a filter over the all-workspaces queue — job history spans every workspace there and says which one each job ran in | | MCP | `list_workspaces`, then `use_workspace(name)`. It is a session default rather than an argument on each call, so switching is one visible step in the transcript instead of a flag that can be forgotten on the call where it mattered | | HTTP | `?workspace=` on the route, or `"workspace"` in a `POST /api/jobs` body | -| Server page | The Workspaces section lists them, creates and deletes them | +| Web UI (create/delete) | The sidebar's `+ new` creates one; a workspace's own Overview page deletes it (disabled for `default`) | A job carries its own workflow, asset and output directories, so it stays in the workspace it was submitted from however many others the server serves diff --git a/docs/superpowers/plans/2026-09-21-workspace-centric-ui.md b/docs/superpowers/plans/2026-09-21-workspace-centric-ui.md new file mode 100644 index 00000000..b1bbc060 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-workspace-centric-ui.md @@ -0,0 +1,2020 @@ +# Workspace-centric web UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the workspace the landing point and frame of the web UI: a collapsible sidebar of workspaces (each with Overview, Workflows, Jobs, Gallery, Assets, Editor), a `Shared` group (Prompts, Assets, Examples) and a `Server` group (Models, Schema, Status), with the workspace carried in the URL. + +**Architecture:** The hash route is the single source of truth for the selected workspace. `lib/routes.ts` parses `#/ws//
/...`, `#/shared/...`, `#/server/...` into a typed view and maps every legacy hash to its new home; `router.svelte.ts` applies redirects with `location.replace` and, on every route change, sets `workspace.current` from the route (falling back to the last-used name off a `ws` route). `api.ts`'s `scoped()` keeps reading `workspace.current`, so no page's data fetching changes - pages only change the links they build. `App.svelte` becomes sidebar + header + content. No server changes. + +**Tech Stack:** Svelte 5 runes, TypeScript, Vitest + @testing-library/svelte, Playwright, lucide icons. Commands run from `ui/`: `npm test`, `npm run check`, `npm run lint`, `npm run build`, `npx playwright test` (starts its own server on 8971 - do not have `dw.serve` on that port). + +**Spec:** `docs/superpowers/specs/2026-09-21-workspace-centric-ui-design.md` + +## Global Constraints + +- No server-side change. Every route the UI needs exists (`GET/POST/DELETE /api/workspaces`, `?workspace=` on scoped routes, `GET /api/jobs?workspace=&limit=`). +- Design system (`ui/CLAUDE.md`): no accent colour in chrome; `--live` only for the running job, VRAM pressure and focus; names the engine resolves (workspace names, section names, workflow names) in `--font-mono`; the selected sidebar entry reads as a heavier ink edge, not a colour; one filled button per view. +- Every `localStorage` read/write is wrapped in try/catch (private mode). Use `storageGet`/`storageSet` from `lib/storage.ts` for JSON values. +- Reserved workspace names: `workflows`, `prompts`, `assets`, `outputs`, `exports`, `common`. +- The default workspace is named `default` (`DEFAULT_WORKSPACE` in `lib/workspace.svelte.ts`); it cannot be deleted. +- Legacy localStorage key `dw-workspace` keeps meaning "last-used workspace" so an existing install lands where it was. +- Commit after each task. Commit message prefix `feat(ui):` / `refactor(ui):` / `test(ui):` / `docs(ui):`, ending with `Co-Authored-By: Claude Opus 5 `. +- Run `npm run check && npm run lint && npm test` before every commit; `npm run build` before the final one (the server serves `ui/dist`, so the UI is invisible until built). + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `ui/src/lib/routes.ts` (new) | Pure: `parseView(parts)` → typed `RouteView`; `legacyRedirect(parts, lastUsed)`; `wsHref`, `sharedHref`, `serverHref` builders; section/group constants | +| `ui/src/lib/routes.test.ts` (new) | Parse and redirect tables | +| `ui/src/lib/router.svelte.ts` (modify) | Applies redirects, exposes `route.view`, calls `applyRouteWorkspace` on change, adds `goWs` | +| `ui/src/lib/workspace.svelte.ts` (modify) | `current` set from the route; `lastUsedWorkspace()` / `rememberWorkspace()`; unknown-workspace redirect + toast in `loadWorkspaces` | +| `ui/src/lib/workspaceActions.ts` (new) | `RESERVED_WORKSPACE_NAMES`, `workspaceNameError`, `createWorkspaceAndGo`, `deleteWorkspaceWithConfirm` - shared by Sidebar and Overview | +| `ui/src/lib/proofs.ts` (new) | `latestProofs(files)` - the newest gallery entry per workflow folder, lifted out of `WorkflowsPage` for the Overview | +| `ui/src/lib/Sidebar.svelte` (new) | The three groups, expansion, collapse persistence, `+ new` | +| `ui/src/lib/Breadcrumb.svelte` (new) | `studio / gallery` from `route.view` | +| `ui/src/lib/pages/OverviewPage.svelte` (new) | Workspace landing page | +| `ui/src/lib/pages/StatusPage.svelte` (new) | `Server → Status`: `ServerPage` content above an all-workspaces `JobsPage` | +| `ui/src/App.svelte` (modify) | Layout, header, route → page switch | +| `ui/src/main.ts` (modify) | No `restoreWorkspace()`; the router sets the workspace at load | +| `ui/src/lib/WorkspacePicker.svelte` (delete) | Replaced by the sidebar | +| Pages (modify links) | `EditorPage`, `PromptsPage`, `PromptEditorPage`, `JobPage`, `JobsPage`, `WorkflowPage`, `WorkflowsPage`, `GalleryPage`, `AssetsPage`, `ServerPage` | +| `ui/e2e/workspaces.spec.ts` (new), `ui/e2e/smoke.spec.ts` (modify) | Legacy-hash landing, sidebar switching, job redirect | +| `docs/SERVER.md`, `ui/CLAUDE.md` (modify) | Pages section around the three groups; scoping paragraph | + +--- + +### Task 1: Route parsing and legacy redirects (`routes.ts`) + +**Files:** +- Create: `ui/src/lib/routes.ts` +- Test: `ui/src/lib/routes.test.ts` + +**Interfaces:** +- Produces: + ```ts + export const WS_SECTIONS = ['overview','workflows','jobs','gallery','assets','edit'] as const + export type WsSection = (typeof WS_SECTIONS)[number] + export const SHARED_SECTIONS = ['prompts','prompt-edit','assets','examples'] as const + export type SharedSection = (typeof SHARED_SECTIONS)[number] + export const SERVER_SECTIONS = ['models','schema','status'] as const + export type ServerSection = (typeof SERVER_SECTIONS)[number] + export type RouteView = + | { kind: 'ws'; workspace: string; section: WsSection; rest: string[] } + | { kind: 'shared'; section: SharedSection; rest: string[] } + | { kind: 'server'; section: ServerSection } + export function parseView(parts: string[]): RouteView | null // null = legacy or unknown + export function legacyRedirect(parts: string[], lastUsed: string): string[] | null + export function wsHref(workspace: string, section: WsSection, ...rest: string[]): string + export function sharedHref(section: SharedSection, ...rest: string[]): string + export function serverHref(section: ServerSection): string + ``` + +- [ ] **Step 1: Write the failing tests** + +`ui/src/lib/routes.test.ts`: +```ts +import { describe, expect, it } from 'vitest' +import { + legacyRedirect, + parseView, + serverHref, + sharedHref, + wsHref, +} from './routes' + +describe('parseView', () => { + it('reads a workspace route with its section and rest', () => { + expect(parseView(['ws', 'studio', 'jobs', 'abc'])).toEqual({ + kind: 'ws', + workspace: 'studio', + section: 'jobs', + rest: ['abc'], + }) + }) + it('defaults a bare workspace, or an unknown section, to overview', () => { + expect(parseView(['ws', 'studio'])).toMatchObject({ section: 'overview' }) + expect(parseView(['ws', 'studio', 'nope'])).toMatchObject({ + section: 'overview', + rest: [], + }) + }) + it('keeps a slash-joined name in rest', () => { + expect( + parseView(['ws', 'default', 'workflows', 'models', 'z-image']), + ).toMatchObject({ rest: ['models', 'z-image'] }) + }) + it('reads shared and server routes', () => { + expect(parseView(['shared', 'prompts'])).toEqual({ + kind: 'shared', + section: 'prompts', + rest: [], + }) + expect(parseView(['server', 'models'])).toEqual({ + kind: 'server', + section: 'models', + }) + }) + it('answers null for a legacy or empty hash', () => { + expect(parseView([])).toBeNull() + expect(parseView(['gallery'])).toBeNull() + expect(parseView(['ws'])).toBeNull() + expect(parseView(['shared', 'nope'])).toBeNull() + expect(parseView(['server'])).toBeNull() + }) +}) + +describe('legacyRedirect', () => { + const last = 'studio' + it.each([ + [[], ['ws', 'studio', 'overview']], + [['workflows'], ['ws', 'studio', 'workflows']], + [['workflows', 'models', 'z'], ['ws', 'studio', 'workflows', 'models', 'z']], + [['gallery'], ['ws', 'studio', 'gallery']], + [['assets'], ['ws', 'studio', 'assets']], + [['edit'], ['ws', 'studio', 'edit']], + [['edit', 'a', 'b'], ['ws', 'studio', 'edit', 'a', 'b']], + [['jobs'], ['server', 'status']], + [['jobs', 'j1'], ['ws', 'studio', 'jobs', 'j1']], + [['prompts'], ['shared', 'prompts']], + [['prompt-edit', 'p'], ['shared', 'prompt-edit', 'p']], + [['models'], ['server', 'models']], + [['schema'], ['server', 'schema']], + [['server'], ['server', 'status']], + [['ws'], ['ws', 'studio', 'overview']], + [['shared'], ['shared', 'prompts']], + [['server', 'nope'], ['server', 'status']], + ])('%j -> %j', (from, to) => { + expect(legacyRedirect(from, last)).toEqual(to) + }) + it('leaves a parseable route alone', () => { + expect(legacyRedirect(['ws', 'x', 'gallery'], last)).toBeNull() + expect(legacyRedirect(['shared', 'assets'], last)).toBeNull() + }) + it('sends an unknown top level to the overview', () => { + expect(legacyRedirect(['whatever'], last)).toEqual([ + 'ws', + 'studio', + 'overview', + ]) + }) +}) + +describe('href builders', () => { + it('encode every segment', () => { + expect(wsHref('my ws', 'workflows', 'a/b')).toBe( + '#/ws/my%20ws/workflows/a%2Fb', + ) + expect(sharedHref('prompt-edit', 'p')).toBe('#/shared/prompt-edit/p') + expect(serverHref('models')).toBe('#/server/models') + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ui && npx vitest run src/lib/routes.test.ts` +Expected: FAIL - cannot resolve `./routes`. + +- [ ] **Step 3: Implement `routes.ts`** + +```ts +/** The shape of the hash: '#/ws//
/...', '#/shared/
/...', + * '#/server/
'. Pure - the router applies it. Anything else is a + * legacy hash (the pre-workspace '#/gallery' and friends) and is mapped to + * its new home by `legacyRedirect`, so an old bookmark still lands. */ + +export const WS_SECTIONS = [ + 'overview', + 'workflows', + 'jobs', + 'gallery', + 'assets', + 'edit', +] as const +export type WsSection = (typeof WS_SECTIONS)[number] + +export const SHARED_SECTIONS = [ + 'prompts', + 'prompt-edit', + 'assets', + 'examples', +] as const +export type SharedSection = (typeof SHARED_SECTIONS)[number] + +export const SERVER_SECTIONS = ['models', 'schema', 'status'] as const +export type ServerSection = (typeof SERVER_SECTIONS)[number] + +export type RouteView = + | { kind: 'ws'; workspace: string; section: WsSection; rest: string[] } + | { kind: 'shared'; section: SharedSection; rest: string[] } + | { kind: 'server'; section: ServerSection } + +const isWs = (s: string): s is WsSection => + (WS_SECTIONS as readonly string[]).includes(s) +const isShared = (s: string): s is SharedSection => + (SHARED_SECTIONS as readonly string[]).includes(s) +const isServer = (s: string): s is ServerSection => + (SERVER_SECTIONS as readonly string[]).includes(s) + +export function parseView(parts: string[]): RouteView | null { + const [group, second, third, ...rest] = parts + if (group === 'ws' && second) { + // An unknown section is the overview rather than a 404: the workspace + // is the thing the link names, and the overview is where it starts + if (third === undefined) { + return { kind: 'ws', workspace: second, section: 'overview', rest: [] } + } + if (isWs(third)) return { kind: 'ws', workspace: second, section: third, rest } + return { kind: 'ws', workspace: second, section: 'overview', rest: [] } + } + if (group === 'shared' && second && isShared(second)) { + return { kind: 'shared', section: second, rest: third ? [third, ...rest] : [] } + } + if (group === 'server' && second && isServer(second)) { + return { kind: 'server', section: second } + } + return null +} + +/** Where a hash that `parseView` refuses should go. `lastUsed` is the + * workspace the scoped legacy routes land in. Null when nothing needs + * redirecting. */ +export function legacyRedirect( + parts: string[], + lastUsed: string, +): string[] | null { + if (parseView(parts)) return null + const [top, ...rest] = parts + const ws = (section: WsSection, ...tail: string[]) => [ + 'ws', + lastUsed, + section, + ...tail, + ] + switch (top) { + case undefined: + case '': + case 'ws': + return ws('overview') + case 'workflows': + return ws('workflows', ...rest) + case 'gallery': + return ws('gallery') + case 'assets': + return ws('assets') + case 'edit': + return ws('edit', ...rest) + case 'jobs': + // The queue spans every workspace; one job belongs to one. The job's + // own workspace is not known until it loads, so the page corrects the + // URL then (JobPage) - lastUsed is the first guess + return rest.length ? ws('jobs', ...rest) : ['server', 'status'] + case 'prompts': + return ['shared', 'prompts'] + case 'prompt-edit': + return ['shared', 'prompt-edit', ...rest] + case 'shared': + return ['shared', 'prompts'] + case 'models': + return ['server', 'models'] + case 'schema': + return ['server', 'schema'] + case 'server': + return ['server', 'status'] + default: + return ws('overview') + } +} + +const join = (parts: string[]) => + '#/' + parts.map(encodeURIComponent).join('/') + +export function wsHref( + workspace: string, + section: WsSection, + ...rest: string[] +): string { + return join(['ws', workspace, section, ...rest]) +} +export function sharedHref(section: SharedSection, ...rest: string[]): string { + return join(['shared', section, ...rest]) +} +export function serverHref(section: ServerSection): string { + return join(['server', section]) +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd ui && npx vitest run src/lib/routes.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/lib/routes.ts ui/src/lib/routes.test.ts +git commit -m "feat(ui): route table for workspace-centric hashes with legacy redirects" +``` + +--- + +### Task 2: Workspace follows the route (`workspace.svelte.ts`, `router.svelte.ts`, `main.ts`) + +**Files:** +- Modify: `ui/src/lib/workspace.svelte.ts` +- Modify: `ui/src/lib/router.svelte.ts` +- Modify: `ui/src/main.ts` +- Test: `ui/src/lib/workspace.svelte.test.ts`, `ui/src/lib/router.svelte.test.ts` (new) + +**Interfaces:** +- Consumes: `parseView`, `legacyRedirect` from Task 1. +- Produces: + ```ts + // workspace.svelte.ts + export const workspace: { current: string; names: string[] | undefined; root: string | null; usage: Record } + export function lastUsedWorkspace(): string // localStorage 'dw-workspace' or 'default' + export function rememberWorkspace(name: string): void + export function applyRouteWorkspace(view: RouteView | null): void // sets workspace.current + export function loadWorkspaces(): Promise // unchanged signature; redirects+toasts on an unknown route workspace + export function invalidateWorkspaces(): void + // removed: selectWorkspace, restoreWorkspace + // router.svelte.ts + export const route: { parts: string[]; view: RouteView } + export function go(...parts: string[]): void + export function goWs(section: WsSection, ...rest: string[]): void // under workspace.current + ``` + +- [ ] **Step 1: Write the failing workspace tests** + +Replace `ui/src/lib/workspace.svelte.test.ts` with: +```ts +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const listWorkspaces = vi.hoisted(() => + vi.fn(() => + Promise.resolve({ + workspace_root: '/ws', + default: 'default', + workspaces: [ + { name: 'default', default: true, workflows: '/ws/workflows', assets: null, outputs: '/ws/outputs', prompts: null }, + { name: 'studio', default: false, workflows: '/ws/studio/workflows', assets: null, outputs: '/ws/studio/outputs', prompts: null }, + ], + }), + ), +) +const errorToast = vi.hoisted(() => vi.fn()) +vi.mock('./api', () => ({ api: { listWorkspaces: () => listWorkspaces() } })) +vi.mock('./toast', () => ({ notify: { error: errorToast, success: vi.fn() } })) + +beforeEach(() => { + listWorkspaces.mockClear() + errorToast.mockClear() + localStorage.clear() + location.hash = '' +}) +afterEach(() => vi.resetModules()) + +it('shares one fetch across concurrent and repeated calls', async () => { + const { loadWorkspaces, workspace } = await import('./workspace.svelte') + await Promise.all([loadWorkspaces(), loadWorkspaces()]) + expect(listWorkspaces).toHaveBeenCalledTimes(1) + expect(workspace.names).toEqual(['default', 'studio']) +}) + +it('refetches only after invalidateWorkspaces()', async () => { + const { invalidateWorkspaces, loadWorkspaces } = await import('./workspace.svelte') + await loadWorkspaces() + await loadWorkspaces() + expect(listWorkspaces).toHaveBeenCalledTimes(1) + invalidateWorkspaces() + await loadWorkspaces() + expect(listWorkspaces).toHaveBeenCalledTimes(2) +}) + +it('a ws route sets current and is remembered as last used', async () => { + const { applyRouteWorkspace, lastUsedWorkspace, workspace } = await import('./workspace.svelte') + applyRouteWorkspace({ kind: 'ws', workspace: 'studio', section: 'gallery', rest: [] }) + expect(workspace.current).toBe('studio') + expect(lastUsedWorkspace()).toBe('studio') + expect(localStorage.getItem('dw-workspace')).toBe('studio') +}) + +it('the default workspace clears the stored last-used name', async () => { + localStorage.setItem('dw-workspace', 'studio') + const { applyRouteWorkspace, lastUsedWorkspace } = await import('./workspace.svelte') + applyRouteWorkspace({ kind: 'ws', workspace: 'default', section: 'overview', rest: [] }) + expect(localStorage.getItem('dw-workspace')).toBeNull() + expect(lastUsedWorkspace()).toBe('default') +}) + +it('a shared or server route keeps scoping to the last-used workspace', async () => { + localStorage.setItem('dw-workspace', 'studio') + const { applyRouteWorkspace, workspace } = await import('./workspace.svelte') + applyRouteWorkspace({ kind: 'shared', section: 'prompts', rest: [] }) + expect(workspace.current).toBe('studio') + applyRouteWorkspace({ kind: 'server', section: 'models' }) + expect(workspace.current).toBe('studio') +}) + +it('an unknown workspace in the route redirects to default and says so', async () => { + location.hash = '#/ws/gone/gallery' + // The router is what applies the hash to `workspace.current` + await import('./router.svelte') + const { loadWorkspaces } = await import('./workspace.svelte') + await loadWorkspaces() + expect(location.hash).toBe('#/ws/default/overview') + expect(errorToast).toHaveBeenCalledWith(expect.stringContaining('gone')) +}) + +it('a known workspace in the route is left alone', async () => { + location.hash = '#/ws/studio/gallery' + await import('./router.svelte') + const { loadWorkspaces } = await import('./workspace.svelte') + await loadWorkspaces() + expect(location.hash).toBe('#/ws/studio/gallery') + expect(errorToast).not.toHaveBeenCalled() +}) +``` + +- [ ] **Step 2: Write the failing router tests** + +`ui/src/lib/router.svelte.test.ts`: +```ts +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +vi.mock('./api', () => ({ api: { listWorkspaces: vi.fn(() => new Promise(() => {})) } })) +vi.mock('./toast', () => ({ notify: { error: vi.fn(), success: vi.fn() } })) + +beforeEach(() => { + localStorage.clear() + location.hash = '' +}) +afterEach(() => vi.resetModules()) + +const tick = () => new Promise((r) => setTimeout(r, 0)) + +it('redirects a legacy hash to the last-used workspace at load', async () => { + localStorage.setItem('dw-workspace', 'studio') + location.hash = '#/gallery' + const { route } = await import('./router.svelte') + expect(location.hash).toBe('#/ws/studio/gallery') + expect(route.view).toEqual({ kind: 'ws', workspace: 'studio', section: 'gallery', rest: [] }) +}) + +it('an empty hash lands on the default overview', async () => { + const { route } = await import('./router.svelte') + expect(location.hash).toBe('#/ws/default/overview') + expect(route.view).toMatchObject({ kind: 'ws', workspace: 'default', section: 'overview' }) +}) + +it('a hash change updates the view and the current workspace', async () => { + const { route } = await import('./router.svelte') + const { workspace } = await import('./workspace.svelte') + location.hash = '#/ws/studio/jobs/j1' + await tick() + expect(route.view).toMatchObject({ workspace: 'studio', section: 'jobs', rest: ['j1'] }) + expect(workspace.current).toBe('studio') + location.hash = '#/shared/prompts' + await tick() + expect(route.view).toMatchObject({ kind: 'shared', section: 'prompts' }) + expect(workspace.current).toBe('studio') +}) + +it('goWs builds a link under the current workspace', async () => { + const { goWs } = await import('./router.svelte') + location.hash = '#/ws/studio/overview' + await tick() + goWs('jobs', 'j1') + expect(location.hash).toBe('#/ws/studio/jobs/j1') +}) +``` + +- [ ] **Step 3: Run to verify they fail** + +Run: `cd ui && npx vitest run src/lib/workspace.svelte.test.ts src/lib/router.svelte.test.ts` +Expected: FAIL - `applyRouteWorkspace`, `lastUsedWorkspace` are not exported; `route.view` undefined. + +- [ ] **Step 4: Rewrite `workspace.svelte.ts`** + +```ts +import { api } from './api' +import { notify } from './toast' +import type { RouteView } from './routes' + +const STORAGE_KEY = 'dw-workspace' +export const DEFAULT_WORKSPACE = 'default' + +/** Which workspace the UI is looking at, and what the server offers. + * + * The route is the source of truth: `applyRouteWorkspace` (called by the + * router on every change) sets `current` from a `#/ws//...` hash, and + * off one (a shared or server page) leaves it at the last workspace a `ws` + * route named, so a scoped request from those pages still means something. + * `api.ts` reads `current` directly to scope every request, so a page only + * has to read `current` inside its load effect to refetch on a switch. + * `names` stays undefined until a listing lands, so "not loaded yet" is + * distinguishable from "only the default exists". */ +export const workspace = $state<{ + current: string + names: string[] | undefined + root: string | null + /** Roughly how much disk each workspace holds, by name - the server + * computes it per listing and caches it briefly, so it is a glance + * rather than a live figure. Absent for a server that does not send it. */ + usage: Record +}>({ current: DEFAULT_WORKSPACE, names: undefined, root: null, usage: {} }) + +/** The workspace a bare legacy hash ('#/gallery') lands in: the last one a + * route named, so a reload or an old bookmark goes back where the user was. */ +export function lastUsedWorkspace(): string { + try { + return localStorage.getItem(STORAGE_KEY) || DEFAULT_WORKSPACE + } catch { + return DEFAULT_WORKSPACE + } +} + +export function rememberWorkspace(name: string): void { + try { + if (name === DEFAULT_WORKSPACE) localStorage.removeItem(STORAGE_KEY) + else localStorage.setItem(STORAGE_KEY, name) + } catch { + /* the selection still holds for this session */ + } +} + +export function applyRouteWorkspace(view: RouteView | null): void { + if (view?.kind === 'ws') { + workspace.current = view.workspace + rememberWorkspace(view.workspace) + } else { + workspace.current = lastUsedWorkspace() + } +} + +// The in-flight/completed listing, shared across every caller - the sidebar +// and any page that needs names call loadWorkspaces() and would otherwise +// refetch a listing that has not changed. +let workspacesPromise: Promise | null = null + +export function loadWorkspaces(): Promise { + if (!workspacesPromise) { + workspacesPromise = (async () => { + try { + const result = await api.listWorkspaces() + workspace.names = result.workspaces.map((entry) => entry.name) + workspace.root = result.workspace_root + workspace.usage = Object.fromEntries( + result.workspaces + .filter((entry) => entry.usage) + .map((entry) => [entry.name, entry.usage!]), + ) + // A route naming a workspace that has gone away (deleted elsewhere, + // or a stale bookmark) would scope every request to a 404. Said out + // loud rather than silently swapped, since the URL was explicit. + if (!workspace.names.includes(workspace.current)) { + const missing = workspace.current + rememberWorkspace(DEFAULT_WORKSPACE) + workspace.current = DEFAULT_WORKSPACE + notify.error(`No workspace named ${missing} - showing default`) + location.replace('#/ws/default/overview') + } + } catch { + workspace.names = [DEFAULT_WORKSPACE] + } + })() + } + return workspacesPromise +} + +/** Drop the cached listing so the next `loadWorkspaces()` refetches - call + * after a create or delete changes what the server offers. */ +export function invalidateWorkspaces(): void { + workspacesPromise = null +} +``` + +Note: the unknown-workspace branch runs for a `shared`/`server` route too when `lastUsedWorkspace()` is stale - correct, since every scoped request from those pages would 404; the redirect to the default overview is the right landing either way. + +- [ ] **Step 5: Rewrite `router.svelte.ts`** + +```ts +import { legacyRedirect, parseView, type RouteView, type WsSection } from './routes' +import { applyRouteWorkspace, lastUsedWorkspace, workspace } from './workspace.svelte' + +/** Minimal hash router: '#/ws/studio/jobs/abc' -> parts ['ws','studio','jobs','abc'] + * and a typed `view`. A legacy hash is rewritten in place (no history entry) + * before it is parsed, so '#/gallery' lands in the last-used workspace. */ +function decode(part: string): string { + // A hand-edited or truncated hash can hold a stray '%', which + // decodeURIComponent throws on - at module load that would blank the whole + // app, so an undecodable segment is used as it was typed + try { + return decodeURIComponent(part) + } catch { + return part + } +} + +function split(): string[] { + const hash = location.hash.replace(/^#\/?/, '') + return hash ? hash.split('/').map(decode) : [] +} + +function parse(): { parts: string[]; view: RouteView } { + let parts = split() + const redirect = legacyRedirect(parts, lastUsedWorkspace()) + if (redirect) { + location.replace('#/' + redirect.map(encodeURIComponent).join('/')) + parts = redirect + } + const view = parseView(parts)! + applyRouteWorkspace(view) + return { parts, view } +} + +export const route = $state(parse()) + +window.addEventListener('hashchange', () => { + const next = parse() + route.parts = next.parts + route.view = next.view +}) + +export function go(...parts: string[]) { + location.hash = '/' + parts.map(encodeURIComponent).join('/') +} + +/** Navigate to a section of the workspace the UI is in. */ +export function goWs(section: WsSection, ...rest: string[]) { + go('ws', workspace.current, section, ...rest) +} +``` + +`location.replace` on a hash fires `hashchange` too; `parse()` on that second pass finds nothing to redirect. That is one extra parse, not a loop. + +- [ ] **Step 6: Update `main.ts`** + +Remove the `restoreWorkspace` import and call and the comment above it. The router module (imported by `App.svelte`) sets the workspace at load, before any page effect runs. + +- [ ] **Step 7: Run the tests** + +Run: `cd ui && npx vitest run src/lib/workspace.svelte.test.ts src/lib/router.svelte.test.ts` +Expected: PASS. + +Then `npm run check` - it will fail in `ServerPage.svelte` (imports `selectWorkspace`) and `WorkspacePicker.svelte`. Leave those; Task 3 and Task 5 fix them. Do not commit a red `check` - fold Step 8 into Task 3's commit if you prefer one green commit; otherwise stub: in `ServerPage.svelte` replace `selectWorkspace(DEFAULT_WORKSPACE)` with `go('ws', DEFAULT_WORKSPACE, 'overview')` (import `go` from `../router.svelte`) and in `WorkspacePicker.svelte` replace `selectWorkspace(value)` with `go('ws', value, route.view.kind === 'ws' ? route.view.section : 'overview')` (import `route`, `go`). Both are deleted/reworked later. + +- [ ] **Step 8: Commit** + +```bash +cd ui && npm run check && npm run lint && npm test +git add ui/src/lib/workspace.svelte.ts ui/src/lib/workspace.svelte.test.ts ui/src/lib/router.svelte.ts ui/src/lib/router.svelte.test.ts ui/src/main.ts ui/src/lib/pages/ServerPage.svelte ui/src/lib/WorkspacePicker.svelte +git commit -m "feat(ui): the route is the source of truth for the selected workspace" +``` + +--- + +### Task 3: Shared workspace actions and proofs helper + +**Files:** +- Create: `ui/src/lib/workspaceActions.ts`, `ui/src/lib/workspaceActions.test.ts` +- Create: `ui/src/lib/proofs.ts`, `ui/src/lib/proofs.test.ts` +- Modify: `ui/src/lib/pages/WorkflowsPage.svelte` (use `latestProofs`) +- Modify: `ui/src/lib/pages/ServerPage.svelte` (remove the workspaces panel and its script) +- Modify: `ui/src/lib/pages/ServerPage.test.ts` (drop any assertion on the workspaces panel, if present) + +**Interfaces:** +- Produces: + ```ts + // workspaceActions.ts + export const RESERVED_WORKSPACE_NAMES: readonly string[] + export function workspaceNameError(name: string): string | null + export async function createWorkspaceAndGo(name: string): Promise // null on success (navigated), else message + export async function deleteWorkspaceWithConfirm(name: string): Promise // true when deleted (navigated to default overview) + // proofs.ts + export function latestProofs(files: GalleryFile[]): Record + ``` + +- [ ] **Step 1: Write the failing tests** + +`ui/src/lib/workspaceActions.test.ts`: +```ts +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const createWorkspace = vi.hoisted(() => vi.fn()) +const deleteWorkspace = vi.hoisted(() => vi.fn()) +const confirm = vi.hoisted(() => vi.fn()) +vi.mock('./api', () => ({ + api: { + createWorkspace: (n: string) => createWorkspace(n), + deleteWorkspace: (n: string, ack?: boolean) => deleteWorkspace(n, ack), + listWorkspaces: vi.fn(() => + Promise.resolve({ workspace_root: '/ws', default: 'default', workspaces: [{ name: 'default', default: true }] }), + ), + }, +})) +vi.mock('./confirm.svelte', () => ({ confirmDialog: (...a: unknown[]) => confirm(...a) })) +vi.mock('./toast', () => ({ notify: { error: vi.fn(), success: vi.fn() } })) + +beforeEach(() => { + createWorkspace.mockReset() + deleteWorkspace.mockReset() + confirm.mockReset() + localStorage.clear() + location.hash = '#/ws/default/overview' +}) +afterEach(() => vi.resetModules()) + +it('refuses reserved and malformed names before asking the server', async () => { + const { workspaceNameError } = await import('./workspaceActions') + expect(workspaceNameError('assets')).toMatch(/reserved/) + expect(workspaceNameError('common')).toMatch(/reserved/) + expect(workspaceNameError('')).toMatch(/name/) + expect(workspaceNameError('a/b')).toMatch(/letters/) + expect(workspaceNameError('studio-2')).toBeNull() +}) + +it('creates, then navigates to the new overview', async () => { + createWorkspace.mockResolvedValue({ name: 'studio' }) + const { createWorkspaceAndGo } = await import('./workspaceActions') + expect(await createWorkspaceAndGo('studio')).toBeNull() + expect(createWorkspace).toHaveBeenCalledWith('studio') + expect(location.hash).toBe('#/ws/studio/overview') +}) + +it('returns the server detail on failure and stays put', async () => { + createWorkspace.mockRejectedValue(new Error('already exists')) + const { createWorkspaceAndGo } = await import('./workspaceActions') + expect(await createWorkspaceAndGo('studio')).toBe('already exists') + expect(location.hash).toBe('#/ws/default/overview') +}) + +it('asks with the server detail, then deletes acknowledged and goes to default', async () => { + deleteWorkspace + .mockRejectedValueOnce(new Error('holds 3 files')) + .mockResolvedValueOnce({ name: 'studio', deleted: true }) + confirm.mockResolvedValue(true) + location.hash = '#/ws/studio/overview' + const { deleteWorkspaceWithConfirm } = await import('./workspaceActions') + expect(await deleteWorkspaceWithConfirm('studio')).toBe(true) + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('holds 3 files'), expect.anything()) + expect(deleteWorkspace).toHaveBeenLastCalledWith('studio', true) + expect(location.hash).toBe('#/ws/default/overview') +}) + +it('a declined confirm deletes nothing', async () => { + deleteWorkspace.mockRejectedValueOnce(new Error('holds 3 files')) + confirm.mockResolvedValue(false) + const { deleteWorkspaceWithConfirm } = await import('./workspaceActions') + expect(await deleteWorkspaceWithConfirm('studio')).toBe(false) + expect(deleteWorkspace).toHaveBeenCalledTimes(1) +}) +``` + +`ui/src/lib/proofs.test.ts`: +```ts +import { expect, it } from 'vitest' +import { latestProofs } from './proofs' +import type { GalleryFile } from './types' + +const file = (name: string, folder: string, kind: GalleryFile['kind']): GalleryFile => ({ + name, folder, subfolder: '', url: '/' + name, kind, size: 1, mtime: 1, label: name, +}) + +it('keeps the first entry seen per folder, preferring an image over a video', () => { + const proofs = latestProofs([ + file('a.mp4', 'shot', 'video'), + file('a.png', 'shot', 'image'), + file('b.png', 'still', 'image'), + file('c.png', 'still', 'image'), + file('loose.png', '', 'image'), + ]) + expect(proofs.shot.name).toBe('a.png') + expect(proofs.still.name).toBe('b.png') + expect(Object.keys(proofs)).toEqual(['shot', 'still']) +}) +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd ui && npx vitest run src/lib/workspaceActions.test.ts src/lib/proofs.test.ts` +Expected: FAIL - modules missing. + +- [ ] **Step 3: Implement `proofs.ts`** (lifted verbatim from `WorkflowsPage.svelte`'s gallery effect) + +```ts +import type { GalleryFile } from './types' + +/** The newest output each workflow has produced, by workflow identity. A run + * writes to ///, and the gallery listing reports + * that identity as an entry's `folder` with the run id already stripped, so + * a workflow's name matches its folder directly. Entries arrive newest + * first, so the first one seen for a folder is that workflow's latest. + * Images win over video because only images have a thumbnail endpoint; a + * video-only workflow falls back to its video, which renders its first + * frame. */ +export function latestProofs(files: GalleryFile[]): Record { + const latest: Record = {} + for (const file of files) { + if (!file.folder) continue + const held = latest[file.folder] + if (!held) latest[file.folder] = file + else if (held.kind !== 'image' && file.kind === 'image') + latest[file.folder] = file + } + return latest +} +``` + +In `WorkflowsPage.svelte` replace the body of the `.then((result) => { ... proofs = latest })` in the gallery effect with `.then((result) => { proofs = latestProofs(result.files) })` and import `latestProofs` from `'../proofs'`. Delete the now-duplicated comment there. + +- [ ] **Step 4: Implement `workspaceActions.ts`** + +```ts +import { api } from './api' +import { confirmDialog } from './confirm.svelte' +import { notify } from './toast' +import { + DEFAULT_WORKSPACE, + invalidateWorkspaces, + loadWorkspaces, +} from './workspace.svelte' + +/** The names a workspace cannot take: the folders the root itself holds + * (dw/workspace.py's RESERVED). Checked here so the sidebar can say why + * before a round trip, but the server has the last word. */ +export const RESERVED_WORKSPACE_NAMES = [ + 'workflows', + 'prompts', + 'assets', + 'outputs', + 'exports', + 'common', +] as const + +const NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/ + +export function workspaceNameError(name: string): string | null { + if (!name) return 'A workspace needs a name' + if ((RESERVED_WORKSPACE_NAMES as readonly string[]).includes(name)) + return `${name} is reserved` + if (!NAME.test(name)) + return 'Use letters, digits, - and _ only' + return null +} + +/** Create a workspace and land on its overview. Answers the message to show + * on failure, null on success. */ +export async function createWorkspaceAndGo(name: string): Promise { + const invalid = workspaceNameError(name) + if (invalid) return invalid + try { + await api.createWorkspace(name) + } catch (e) { + return e instanceof Error ? e.message : String(e) + } + invalidateWorkspaces() + await loadWorkspaces() + notify.success(`Created workspace ${name}`) + location.hash = `#/ws/${encodeURIComponent(name)}/overview` + return null +} + +/** Delete a workspace after an informed confirm - the server's unacknowledged + * refusal says what it holds - and land on the default overview. */ +export async function deleteWorkspaceWithConfirm(name: string): Promise { + if (name === DEFAULT_WORKSPACE) return false + let detail = '' + try { + // Unacknowledged first: the server answers with what it would remove, + // which is what makes the confirmation an informed one + await api.deleteWorkspace(name) + } catch (e) { + detail = e instanceof Error ? e.message : String(e) + } + if ( + !(await confirmDialog(`${detail}\n\nDelete workspace "${name}"?`.trim(), { + confirmLabel: 'Delete', + })) + ) + return false + try { + await api.deleteWorkspace(name, true) + } catch (e) { + notify.error(e instanceof Error ? e.message : String(e)) + return false + } + invalidateWorkspaces() + await loadWorkspaces() + notify.success(`Deleted workspace ${name}`) + location.hash = `#/ws/${DEFAULT_WORKSPACE}/overview` + return true +} +``` + +- [ ] **Step 5: Strip the workspaces panel from `ServerPage.svelte`** + +Remove: the `// ---- workspaces` block (`newWorkspace`, `workspaceError`, `addWorkspace`, `removeWorkspace`), the `

Workspaces

…
` markup, the `.workspaces`, `.workspaces li`, `.workspaces .size`, `.newworkspace` styles, and now-unused imports (`Trash2`, `confirmDialog`, `invalidateWorkspaces`, `loadWorkspaces`, `DEFAULT_WORKSPACE`, `workspace`, `formatBytes`, `go` if Task 2 added it) - keep any still used elsewhere in the file (`notify` is used by the copy buttons; check with `npm run lint`). Remove the `loadWorkspaces()` call in the server effect and its comment. Under the `directories` list, keep the `workspace` directory line as is. Check `ServerPage.test.ts` still passes; delete any assertion about the `Workspaces` heading. + +- [ ] **Step 6: Run the tests** + +Run: `cd ui && npm run check && npm run lint && npm test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/lib/workspaceActions.ts ui/src/lib/workspaceActions.test.ts ui/src/lib/proofs.ts ui/src/lib/proofs.test.ts ui/src/lib/pages/WorkflowsPage.svelte ui/src/lib/pages/ServerPage.svelte ui/src/lib/pages/ServerPage.test.ts +git commit -m "refactor(ui): workspace create/delete and gallery proofs as shared helpers" +``` + +--- + +### Task 4: Sidebar, breadcrumb and the app shell + +**Files:** +- Create: `ui/src/lib/Sidebar.svelte`, `ui/src/lib/Sidebar.test.ts` +- Create: `ui/src/lib/Breadcrumb.svelte` +- Modify: `ui/src/App.svelte` +- Delete: `ui/src/lib/WorkspacePicker.svelte` +- Modify: `ui/src/lib/pages/WorkflowsPage.svelte`, `GalleryPage.svelte`, `AssetsPage.svelte` (remove the picker import and ``) +- Modify: `ui/src/lib/pages/WorkflowsPage.test.ts` (drop the `listWorkspaces` mock line and its comment) + +**Interfaces:** +- Consumes: `route`, `go` (Task 2); `workspace`, `loadWorkspaces` (Task 2); `createWorkspaceAndGo`, `workspaceNameError` (Task 3); `wsHref`, `sharedHref`, `serverHref`, `WS_SECTIONS` (Task 1); `formatBytes` (`lib/format.ts`); `storageGet`/`storageSet` (`lib/storage.ts`). +- Produces: `Sidebar` props `{ collapsed: boolean; onToggle: () => void }`; `Breadcrumb` no props. `App.svelte` renders pages by `route.view` - later tasks add `OverviewPage` and `StatusPage` to that switch; until then `overview` renders `WorkflowsPage` and `status` renders `ServerPage` as placeholders. + +- [ ] **Step 1: Write the failing sidebar tests** + +`ui/src/lib/Sidebar.test.ts`: +```ts +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import Sidebar from './Sidebar.svelte' + +const listWorkspaces = vi.hoisted(() => + vi.fn(() => + Promise.resolve({ + workspace_root: '/ws', + default: 'default', + workspaces: [ + { name: 'default', default: true, usage: { files: 2, bytes: 2048 } }, + { name: 'studio', default: false }, + ], + }), + ), +) +const createWorkspace = vi.hoisted(() => vi.fn()) +vi.mock('./api', () => ({ + api: { + listWorkspaces: () => listWorkspaces(), + createWorkspace: (n: string) => createWorkspace(n), + }, +})) +vi.mock('./toast', () => ({ notify: { error: vi.fn(), success: vi.fn() } })) + +beforeEach(async () => { + localStorage.clear() + location.hash = '#/ws/studio/gallery' + createWorkspace.mockReset() + // A fresh router/workspace module per test so the cached listing and the + // parsed route do not leak between cases + vi.resetModules() + await import('./router.svelte') +}) +afterEach(cleanup) + +it('lists workspaces, expands the current one and marks its section', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + await waitFor(() => expect(screen.getByText('studio')).toBeTruthy()) + const gallery = screen.getByRole('link', { name: /gallery/i }) + expect(gallery.getAttribute('href')).toBe('#/ws/studio/gallery') + expect(gallery.getAttribute('aria-current')).toBe('page') + // the other workspace is collapsed: its sections are not rendered + expect(screen.getAllByRole('link', { name: /workflows/i })).toHaveLength(1) + // usage shows beside a workspace the server measured + expect(screen.getByText('2.0 KB')).toBeTruthy() +}) + +it('a collapsed workspace links to its overview', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + expect(screen.getByRole('link', { name: 'default' }).getAttribute('href')).toBe('#/ws/default/overview') +}) + +it('shared and server groups link to their sections', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + expect(screen.getByRole('link', { name: /prompts/i }).getAttribute('href')).toBe('#/shared/prompts') + expect(screen.getByRole('link', { name: /examples/i }).getAttribute('href')).toBe('#/shared/examples') + expect(screen.getByRole('link', { name: /models/i }).getAttribute('href')).toBe('#/server/models') + expect(screen.getByRole('link', { name: /status/i }).getAttribute('href')).toBe('#/server/status') +}) + +it('+ new refuses a reserved name without calling the server', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + await waitFor(() => expect(screen.getByText('studio')).toBeTruthy()) + await fireEvent.click(screen.getByRole('button', { name: /new workspace/i })) + const input = screen.getByPlaceholderText('name') + await fireEvent.input(input, { target: { value: 'assets' } }) + await fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(screen.getByText(/reserved/)).toBeTruthy()) + expect(createWorkspace).not.toHaveBeenCalled() +}) + +it('+ new creates and lands on the new overview', async () => { + createWorkspace.mockResolvedValue({ name: 'fresh' }) + render(Sidebar, { collapsed: false, onToggle: () => {} }) + await waitFor(() => expect(screen.getByText('studio')).toBeTruthy()) + await fireEvent.click(screen.getByRole('button', { name: /new workspace/i })) + const input = screen.getByPlaceholderText('name') + await fireEvent.input(input, { target: { value: 'fresh' } }) + await fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(location.hash).toBe('#/ws/fresh/overview')) +}) + +it('collapsed shows icons only and the toggle reports', async () => { + const onToggle = vi.fn() + render(Sidebar, { collapsed: true, onToggle }) + await fireEvent.click(screen.getByRole('button', { name: /expand sidebar/i })) + expect(onToggle).toHaveBeenCalled() + expect(screen.queryByText('studio')).toBeNull() +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ui && npx vitest run src/lib/Sidebar.test.ts` +Expected: FAIL - `Sidebar.svelte` missing. + +- [ ] **Step 3: Implement `Sidebar.svelte`** + +```svelte + + + + + +``` + +Check the icons exist in `@lucide/svelte` (`LayoutDashboard`, `PanelLeftClose`, `PanelLeftOpen`, `BookCopy`); if one does not, substitute a sibling (`Gauge`, `ChevronsLeft`, `ChevronsRight`, `Library`) - `npm run check` will say. + +- [ ] **Step 4: Implement `Breadcrumb.svelte`** + +```svelte + + + + + +``` + +- [ ] **Step 5: Rework `App.svelte`** + +Script: remove the nav icon imports no longer used (`Database`, `FolderOpen`, `Images`, `Layers`, `ListTodo`, `ListTree`, `MessageSquareText`, `Server`, `SquarePen`), add `Menu` from lucide, import `Sidebar`, `Breadcrumb`, `storageGet`/`storageSet`. Add: + +```ts + let sidebarCollapsed = $state(storageGet('dw-sidebar', false)) + function toggleSidebar() { + sidebarCollapsed = !sidebarCollapsed + storageSet('dw-sidebar', sidebarCollapsed) + } + // Narrow viewports: the sidebar is an overlay opened from the header + let drawerOpen = $state(false) + $effect(() => { + // any navigation closes the drawer + void route.parts + drawerOpen = false + }) + const view = $derived(route.view) + const wide = $derived( + (view.kind === 'ws' && view.section === 'edit') || + (view.kind === 'shared' && view.section === 'prompt-edit'), + ) +``` + +Markup: replace everything from `
` through `` with: + +```svelte +
+ + {#if drawerOpen} + + {/if} +
+
+ +
+ +
+ {#if view.kind === 'server'} + {#if view.section === 'schema'} + {:else if view.section === 'models'} + {:else}{/if} + {:else if view.kind === 'shared'} + {#if view.section === 'prompts'} + {:else if view.section === 'prompt-edit'} + {:else if view.section === 'assets'} + {:else if view.rest.length} + {:else}{/if} + {:else if view.section === 'gallery'} + {:else if view.section === 'assets'} + {:else if view.section === 'edit'} + {:else if view.section === 'jobs' && view.rest[0]} + {:else if view.section === 'jobs'} + {:else if view.section === 'workflows' && view.rest.length} + {:else}{/if} +
+
+
+``` + +(`overview` falls to `WorkflowsPage` until Task 6; `status` to `ServerPage` until Task 7; `shared/assets` and `shared/examples` get their props in Task 5.) Keep `{#key}`-free: the page components already refetch on `workspace.current`. + +Styles: replace the `.navrow` nav-link rules (`nav a`, `.navlabel`, `.navrule`, `.second`, `.active`, the 1080px media query on nav) with: + +```css + .shell { display: flex; min-height: 100vh; } + .column { flex: 1; min-width: 0; display: flex; flex-direction: column; } + header { border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 10; } + .navrow { display: flex; align-items: center; gap: 0.5rem 1rem; padding: 0.55rem 1.2rem; } + .flex { flex: 1; } + .menu { display: none; } + .scrim { display: none; } + @media (max-width: 900px) { + .menu { display: inline-flex; } + .shell :global(aside) { position: fixed; inset: 0 auto 0 0; z-index: 20; transform: translateX(-100%); transition: transform 0.15s ease; } + .shell.drawer :global(aside) { transform: none; } + .shell.drawer .scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.35); border: 0; } + } +``` + +Keep the existing `main` / `main.wide` / `.state` / `.meter` / `.live` / `.pulse-dot` rules. Remove the "One row..." comment above the header and replace with one line: ``. + +- [ ] **Step 6: Delete the picker** + +`git rm ui/src/lib/WorkspacePicker.svelte`. In `WorkflowsPage.svelte`, `GalleryPage.svelte`, `AssetsPage.svelte` remove the import and the `` element. In `WorkflowsPage.test.ts` remove the `listWorkspaces` mock and the two comment lines above it. In `AssetsPage.svelte`'s HintBar text change "only this workspace's own section changes with the picker" to "only this workspace's own section changes between workspaces". + +- [ ] **Step 7: Run everything** + +Run: `cd ui && npm run check && npm run lint && npm test` +Expected: PASS. Then `npm run dev` and eyeball at 1200px and 600px: sidebar collapses, drawer opens, breadcrumb reads `default / workflows`. + +- [ ] **Step 8: Commit** + +```bash +git add -A ui/src +git commit -m "feat(ui): sidebar of workspaces, shared and server groups replaces the top nav" +``` + +--- + +### Task 5: Pages link into the new routes; per-page props + +**Files:** +- Modify: `ui/src/lib/pages/EditorPage.svelte`, `PromptsPage.svelte`, `PromptEditorPage.svelte`, `JobPage.svelte`, `JobsPage.svelte`, `WorkflowPage.svelte`, `WorkflowsPage.svelte`, `GalleryPage.svelte`, `AssetsPage.svelte` +- Modify: `ui/src/lib/api.ts` (`listJobs` gains `limit`) +- Modify: `ui/src/App.svelte` (pass the new props) +- Test: `ui/src/lib/pages/WorkflowsPage.test.ts`, `ui/src/lib/pages/AssetsPage.test.ts`, `ui/src/lib/pages/JobPage.test.ts` + +**Interfaces:** +- Consumes: `goWs`, `route` (Task 2); `wsHref`, `sharedHref` (Task 1). +- Produces: + - `JobsPage` props `{ scope?: 'workspace' | 'all' }` (default `'workspace'`). + - `AssetsPage` props `{ shared?: boolean }` (default false). + - `WorkflowsPage` props `{ examples?: boolean }` (default false). + - `api.listJobs(workspace?: string, limit?: number)`. + +- [ ] **Step 1: Write the failing tests** + +Append to `WorkflowsPage.test.ts` (the mock listing gains `sources` and per-workflow `origin`/`writable`): +```ts +it('the examples view lists only read-only workflows and links under shared', async () => { + listing.details['models/flux-dev'] = { ...listing.details['models/flux-dev'], origin: 'examples', writable: false } + listing.details['templates/tti'] = { ...listing.details['templates/tti'], origin: 'workspace', writable: true } + render(WorkflowsPage, { examples: true }) + await waitFor(() => expect(card('flux-dev')).toBeTruthy()) + expect(card('tti')).toBeNull() + expect(card('flux-dev')!.getAttribute('href')).toBe('#/shared/examples/models/flux-dev') + expect(screen.queryByTitle('new workflow')).toBeNull() +}) + +it('the workspace view links a card under the current workspace', async () => { + location.hash = '#/ws/studio/workflows' + await renderPage('tti') + expect(card('tti')!.getAttribute('href')).toBe('#/ws/studio/workflows/templates/tti') +}) +``` +(Import `location`-setting into `beforeEach`: `location.hash = '#/ws/default/workflows'`. The page reads `workspace.current`, so the router module must be imported in the test file: add `import '../router.svelte'` after the component import.) + +Append to `AssetsPage.test.ts` (find its `libraries` mock; it returns `workspace`, `common`, `examples` entries): +```ts +it('the shared view hides the workspace section and makes shared upload the filled button', async () => { + render(AssetsPage, { shared: true }) + await waitFor(() => expect(screen.getByText(/shared library/i)).toBeTruthy()) + expect(screen.queryByText('This workspace')).toBeNull() + const upload = screen.getByRole('button', { name: /upload to shared/i }) + expect(upload.classList.contains('quiet')).toBe(false) +}) +``` + +Append to `JobPage.test.ts` (find how it mocks `api.getJob`; the job fixture carries `workspace`): +```ts +it('corrects the URL to the job\'s own workspace', async () => { + location.hash = '#/ws/default/jobs/j1' + jobFixture.workspace = 'studio' // whatever the file names its fixture + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(location.hash).toBe('#/ws/studio/jobs/j1')) +}) +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd ui && npx vitest run src/lib/pages` +Expected: the three new cases FAIL. + +- [ ] **Step 3: `api.listJobs` limit** + +```ts + listJobs: (workspace?: string, limit?: number) => { + const query = new URLSearchParams() + if (workspace) query.set('workspace', workspace) + if (limit) query.set('limit', String(limit)) + const qs = query.toString() + return request<{ jobs: JobSummary[]; total?: number }>( + qs ? `/api/jobs?${qs}` : '/api/jobs', + undefined, + { scope: false }, + ) + }, +``` +Update the comment above it: "Unscoped on purpose: `workspace` is explicit so Status can span every workspace and a workspace's Jobs page can name its own." + +- [ ] **Step 4: Update each page** + +- `EditorPage.svelte`: `go('jobs', job.id)` → `goWs('jobs', job.id)`; `href="#/workflows"` → `href={wsHref(workspace.current, 'workflows')}`; find `workflowHref` (the crumb link to the read-only view) and make it `wsHref(workspace.current, 'workflows', ...name.split('/'))`. Import `goWs` from `'../router.svelte'`, `wsHref` from `'../routes'`, `workspace` from `'../workspace.svelte'`. +- `WorkflowPage.svelte`: `go('edit')` → `goWs('edit')`; `go('workflows')` → `goWs('workflows')`; `go('jobs', job.id)` → `goWs('jobs', job.id)`; back link `href="#/workflows"` → `href={route.view.kind === 'shared' ? sharedHref('examples') : wsHref(workspace.current, 'workflows')}`; proof `href="#/gallery"` → `href={wsHref(workspace.current, 'gallery')}`. The `Edit` button/link that opens the editor for this workflow: ensure it goes to `goWs('edit', ...name.split('/'))` (a read-only example opened this way saves a copy into the workspace, as the server already does). +- `WorkflowsPage.svelte`: add `let { examples = false }: { examples?: boolean } = $props()`. Card href helper (`'#/workflows/' + ...`) → `examples ? sharedHref('examples', ...name.split('/')) : wsHref(workspace.current, 'workflows', ...name.split('/'))`. The `visible` derivation gains a first clause: `if (examples && details[name]?.writable !== false) return false`. Wrap the `` in `{#if !examples}` and change its href to `wsHref(workspace.current, 'edit')`. Heading: `{examples ? 'Examples' : 'Workflows'}`. +- `GalleryPage.svelte`: `go('edit')` → `goWs('edit')`. +- `AssetsPage.svelte`: add `let { shared = false }: { shared?: boolean } = $props()`. In `sections`, add `.filter((section) => !shared || section.origin !== 'workspace')`. Where the two upload buttons render, the workspace one is inside `{#if !shared}` already by the section filter; the shared button's class becomes `class:quiet={!shared}` so it is the filled one under Shared. Heading: `{shared ? 'Shared assets' : 'Assets'}`. +- `PromptsPage.svelte`: `href="#/prompt-edit"` → `href={sharedHref('prompt-edit')}`; the per-prompt card href (find `'#/prompt-edit/'`) → `sharedHref('prompt-edit', ...name.split('/'))`. +- `PromptEditorPage.svelte`: `go('prompts')` → `go('shared', 'prompts')`; `go('prompt-edit')` → `go('shared', 'prompt-edit')`; `href="#/prompts"` → `href={sharedHref('prompts')}`. +- `JobPage.svelte`: `go('jobs', id)` (rerun) → `go('ws', job!.workspace, 'jobs', id)` (a rerun keeps its workspace); `href="#/jobs"` → `href={wsHref(job?.workspace ?? workspace.current, 'jobs')}`. Add, where the job first loads (the effect that sets `job = await api.getJob(jobId)`): + ```ts + // A legacy '#/jobs/' lands under the last-used workspace as a first + // guess; the job knows its own, so the URL is corrected once it answers + if (route.view.kind === 'ws' && route.view.workspace !== job.workspace) + location.replace(wsHref(job.workspace, 'jobs', jobId)) + ``` +- `JobsPage.svelte`: add `let { scope = 'workspace' }: { scope?: 'workspace' | 'all' } = $props()`. Replace `workspaceFilter`'s use in the poll with `const filter = scope === 'all' ? workspaceFilter : workspace.current` (read inside the effect so a switch refetches). Wrap the workspace `` that appears on three pages, only when more than one workspace +exists; Jobs spans every workspace behind a filter; workspace create/delete +sits at the bottom of the Server page; and the selection lives in +localStorage rather than the URL, so `#/gallery` means "the gallery of +whatever I last picked" and no link can name a workspace. + +The rework makes the workspace the landing point and the frame every +per-workspace page sits in, with the things every workspace shares and the +things that belong to the server each given their own place. + +## Decisions already made + +- Selecting a workspace lands on an **Overview** page, not straight into a + section. +- The workspace is **part of the URL**. Legacy routes redirect. +- Shared things get **both** a sidebar `Shared` node (the one place to + manage them) **and** stay visible inline as labelled sections inside a + workspace's own pages (so what a workflow can reference is visible where + you compose). Prompts, being one library for every workspace, move to + `Shared` and leave the per-workspace nav. +- Implementation shape: **the route is the single source of truth** for the + selected workspace. `workspace.current` is derived from the route; + localStorage only remembers the last-used workspace for redirects. No + router library. + +## 1. Shell + +``` +┌──────────┬─────────────────────────────────────────────┐ +│ dw │ header: breadcrumb ● running VRAM ☾ ? │ +│ ├─────────────────────────────────────────────┤ +│ ▾ studio │ │ +│ Overview│ content │ +│ Workflows │ +│ Jobs │ │ +│ Gallery │ │ +│ Assets │ │ +│ Editor │ │ +│ ▸ default│ │ +│ ▸ scratch│ │ +│ + new │ │ +│ ──────── │ │ +│ ▾ Shared │ │ +│ Prompts │ │ +│ Assets │ │ +│ Examples│ │ +│ ──────── │ │ +│ ▾ Server │ │ +│ Models │ │ +│ Schema │ │ +│ Status │ │ +└──────────┴─────────────────────────────────────────────┘ +``` + +**Sidebar** (`lib/Sidebar.svelte`), three groups in this order: + +1. **Workspaces** - one entry per name from `workspace.names`, the + current one expanded into its sections (Overview, Workflows, Jobs, + Gallery, Assets, Editor); the others collapsed. Clicking a collapsed + workspace's name goes to its Overview. Each entry shows its usage + (`workspace.usage[name].bytes`, `formatBytes`) as a muted `.num` + figure when the server sends one. `+ new` opens an inline name field + and calls `POST /api/workspaces`; on success it invalidates the listing + and navigates to the new workspace's Overview. The workspace name is + validated client-side against the reserved names (`workflows`, + `prompts`, `assets`, `outputs`, `exports`, `common`) before the request, + and the server's 4xx detail is shown verbatim otherwise. +2. **Shared** - Prompts, Assets, Examples. +3. **Server** - Models, Schema, Status. + +The sidebar collapses to an icon rail; the collapsed state is persisted +under `dw-sidebar` in localStorage (try/catch, as every other storage read). +Below 900px it is collapsed by default and opens as an overlay from a +hamburger in the header. Keyboard: the rail is a `
+ -
- {#if route.parts[0] === 'schema'} - - {:else if route.parts[0] === 'server'} - - {:else if route.parts[0] === 'models'} - - {:else if route.parts[0] === 'gallery'} - - {:else if route.parts[0] === 'assets'} - - {:else if route.parts[0] === 'edit'} - - {:else if route.parts[0] === 'prompt-edit'} - - {:else if route.parts[0] === 'prompts'} - - {:else if route.parts[0] === 'jobs' && route.parts[1]} - - {:else if route.parts[0] === 'jobs'} - - {:else if route.parts[0] === 'workflows' && route.parts[1]} - - {:else} - - {/if} -
- diff --git a/ui/src/lib/Breadcrumb.test.ts b/ui/src/lib/Breadcrumb.test.ts new file mode 100644 index 00000000..c77b6e83 --- /dev/null +++ b/ui/src/lib/Breadcrumb.test.ts @@ -0,0 +1,59 @@ +import { cleanup, render, screen } from '@testing-library/svelte' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import Breadcrumb from './Breadcrumb.svelte' + +vi.mock('./api', () => ({ + api: { listWorkspaces: vi.fn(() => new Promise(() => {})) }, +})) +vi.mock('./toast', () => ({ notify: { error: vi.fn(), success: vi.fn() } })) + +// The component's router parsed the hash at load; drive it the way the +// browser does +function at(hash: string) { + location.hash = hash + window.dispatchEvent(new HashChangeEvent('hashchange')) +} + +beforeEach(() => localStorage.clear()) +afterEach(cleanup) + +const links = () => + screen + .getAllByRole('link') + .map((a) => [a.textContent, a.getAttribute('href')]) + +it('an overview route renders both crumbs even though they share an href', () => { + at('#/ws/studio/overview') + render(Breadcrumb) + expect(links()).toEqual([ + ['studio', '#/ws/studio/overview'], + ['overview', '#/ws/studio/overview'], + ]) +}) + +it('a workspace section crumbs as workspace / section', () => { + at('#/ws/studio/jobs/abc') + render(Breadcrumb) + expect(links()).toEqual([ + ['studio', '#/ws/studio/overview'], + ['jobs', '#/ws/studio/jobs'], + ]) +}) + +it('a shared route crumbs as shared / section', () => { + at('#/shared/prompt-edit/x') + render(Breadcrumb) + expect(links()).toEqual([ + ['shared', '#/shared/prompts'], + ['prompt-edit', '#/shared/prompt-edit'], + ]) +}) + +it('a server route crumbs as server / section', () => { + at('#/server/models') + render(Breadcrumb) + expect(links()).toEqual([ + ['server', '#/server/status'], + ['models', '#/server/models'], + ]) +}) diff --git a/ui/src/lib/Sidebar.svelte b/ui/src/lib/Sidebar.svelte new file mode 100644 index 00000000..1c26f9d6 --- /dev/null +++ b/ui/src/lib/Sidebar.svelte @@ -0,0 +1,389 @@ + + + + + diff --git a/ui/src/lib/Sidebar.test.ts b/ui/src/lib/Sidebar.test.ts new file mode 100644 index 00000000..1ba70fbf --- /dev/null +++ b/ui/src/lib/Sidebar.test.ts @@ -0,0 +1,146 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/svelte' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import Sidebar from './Sidebar.svelte' +import { invalidateWorkspaces, workspace } from './workspace.svelte' + +const listWorkspaces = vi.hoisted(() => + vi.fn(() => + Promise.resolve({ + workspace_root: '/ws', + default: 'default', + workspaces: [ + { name: 'default', default: true, usage: { files: 2, bytes: 2048 } }, + { name: 'studio', default: false }, + ], + }), + ), +) +const createWorkspace = vi.hoisted(() => vi.fn()) +vi.mock('./api', () => ({ + api: { + listWorkspaces: () => listWorkspaces(), + createWorkspace: (n: string) => createWorkspace(n), + }, +})) +vi.mock('./toast', () => ({ notify: { error: vi.fn(), success: vi.fn() } })) + +beforeEach(() => { + localStorage.clear() + createWorkspace.mockReset() + // The one router instance the component imported parsed the hash at load; + // drive it the way the browser does rather than resetting modules, which + // would hand the component a second Svelte runtime. jsdom fires its own + // hashchange later, which reparses the same hash + location.hash = '#/ws/studio/gallery' + window.dispatchEvent(new HashChangeEvent('hashchange')) + // The listing is module state: drop the cached promise and the fields it + // filled, so every case really waits on the mocked listing rather than + // reading what the last one left + invalidateWorkspaces() + workspace.names = undefined + workspace.root = null + workspace.usage = {} +}) +afterEach(cleanup) + +it('lists workspaces, expands the current one and marks its section', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + // 'studio' is the route's and renders before the listing lands; 'default' + // only the listing can supply + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + const gallery = screen.getByRole('link', { name: /gallery/i }) + expect(gallery.getAttribute('href')).toBe('#/ws/studio/gallery') + expect(gallery.getAttribute('aria-current')).toBe('page') + // the other workspace is collapsed: its sections are not rendered + expect(screen.getAllByRole('link', { name: /workflows/i })).toHaveLength(1) + // usage shows beside a workspace the server measured + expect(screen.getByText('2 KB')).toBeTruthy() +}) + +it('a collapsed workspace links to its overview', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + // the link's accessible name carries its usage figure too + expect( + screen.getByRole('link', { name: /^default/ }).getAttribute('href'), + ).toBe('#/ws/default/overview') +}) + +it('shared and server groups link to their sections', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + expect( + screen.getByRole('link', { name: /prompts/i }).getAttribute('href'), + ).toBe('#/shared/prompts') + expect( + screen.getByRole('link', { name: /examples/i }).getAttribute('href'), + ).toBe('#/shared/examples') + expect( + screen.getByRole('link', { name: /models/i }).getAttribute('href'), + ).toBe('#/server/models') + expect( + screen.getByRole('link', { name: /status/i }).getAttribute('href'), + ).toBe('#/server/status') +}) + +it('+ new refuses a reserved name without calling the server', async () => { + render(Sidebar, { collapsed: false, onToggle: () => {} }) + // 'studio' is the route's and renders before the listing lands; 'default' + // only the listing can supply + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + await fireEvent.click(screen.getByRole('button', { name: /new workspace/i })) + const input = screen.getByPlaceholderText('name') + await fireEvent.input(input, { target: { value: 'assets' } }) + await fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(screen.getByText(/reserved/)).toBeTruthy()) + expect(createWorkspace).not.toHaveBeenCalled() +}) + +it('+ new creates and lands on the new overview', async () => { + createWorkspace.mockResolvedValue({ name: 'fresh' }) + render(Sidebar, { collapsed: false, onToggle: () => {} }) + // 'studio' is the route's and renders before the listing lands; 'default' + // only the listing can supply + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + await fireEvent.click(screen.getByRole('button', { name: /new workspace/i })) + const input = screen.getByPlaceholderText('name') + await fireEvent.input(input, { target: { value: 'fresh' } }) + await fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(location.hash).toBe('#/ws/fresh/overview')) +}) + +it('collapsed shows icons only and the toggle reports', async () => { + const onToggle = vi.fn() + render(Sidebar, { collapsed: true, onToggle }) + await fireEvent.click(screen.getByRole('button', { name: /expand sidebar/i })) + expect(onToggle).toHaveBeenCalled() + expect(screen.queryByText('studio')).toBeNull() +}) + +it('the first render is not a switch; changing workspace marks the new one', async () => { + const { container } = render(Sidebar, { + collapsed: false, + onToggle: () => {}, + }) + await waitFor(() => expect(screen.getByText('default')).toBeTruthy()) + expect(container.querySelector('.ws.open.flash')).toBeNull() + location.hash = '#/ws/default/overview' + window.dispatchEvent(new HashChangeEvent('hashchange')) + await waitFor(() => + expect( + container.querySelector('.ws.open.flash .wsname')?.textContent, + ).toContain('default'), + ) + // the sections of the newly expanded workspace are the ones left once + // the old block's outro has run + await waitFor(() => + expect( + screen.getByRole('link', { name: /gallery/i }).getAttribute('href'), + ).toBe('#/ws/default/gallery'), + ) +}) diff --git a/ui/src/lib/WorkspacePicker.svelte b/ui/src/lib/WorkspacePicker.svelte deleted file mode 100644 index 4d1723ad..00000000 --- a/ui/src/lib/WorkspacePicker.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -{#if choices.length > 1} - -{/if} - - diff --git a/ui/src/lib/api.test.ts b/ui/src/lib/api.test.ts index e712e07d..3a13a63c 100644 --- a/ui/src/lib/api.test.ts +++ b/ui/src/lib/api.test.ts @@ -118,6 +118,22 @@ describe('name encoding', () => { }) }) +describe('job listing', () => { + it('passes workspace, status set and limit as query parameters', async () => { + const calls = stubFetch({ ok: true, body: { jobs: [], total: 0 } }) + await api.listJobs('studio', 5, 'succeeded,failed,cancelled') + expect(calls[0][0]).toBe( + '/api/jobs?workspace=studio&status=succeeded%2Cfailed%2Ccancelled&limit=5', + ) + }) + + it('asks for every job when nothing narrows it', async () => { + const calls = stubFetch({ ok: true, body: { jobs: [], total: 0 } }) + await api.listJobs() + expect(calls[0][0]).toBe('/api/jobs') + }) +}) + describe('workflow definition fetch', () => { it('keeps the definition exactly as served, with origin and writable beside it', async () => { const body = { id: 'z-image', steps: [] } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index d5559de5..f3261548 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -299,17 +299,22 @@ export const api = { writable: response.headers.get('X-Workflow-Writable') !== 'false', }), ), - // Unscoped: the jobs list spans every workspace on purpose, with its own - // filter dropdown rather than following wherever the picker points. - // Omitting `workspace` returns jobs from all of them. - listJobs: (workspace?: string) => - request<{ jobs: JobSummary[] }>( - workspace - ? `/api/jobs?workspace=${encodeURIComponent(workspace)}` - : '/api/jobs', + // Unscoped on purpose: `workspace` is explicit so Status can span every + // workspace and a workspace's Jobs page can name its own. `status` is one + // state or a comma-separated set ('succeeded,failed,cancelled'); `limit` + // keeps the newest N of what matched. + listJobs: (workspace?: string, limit?: number, status?: string) => { + const query = new URLSearchParams() + if (workspace) query.set('workspace', workspace) + if (status) query.set('status', status) + if (limit) query.set('limit', String(limit)) + const qs = query.toString() + return request<{ jobs: JobSummary[]; total?: number }>( + qs ? `/api/jobs?${qs}` : '/api/jobs', undefined, { scope: false }, - ), + ) + }, getJob: (id: string) => request(`/api/jobs/${id}`), /** The definition a job ran, for the job page's read-only flow view. * `realized` true means `definition` is the realized copy the run itself diff --git a/ui/src/lib/pages/AssetsPage.svelte b/ui/src/lib/pages/AssetsPage.svelte index 8c13aec9..ebd236a6 100644 --- a/ui/src/lib/pages/AssetsPage.svelte +++ b/ui/src/lib/pages/AssetsPage.svelte @@ -24,12 +24,13 @@ import { notify } from '../toast' import { storageGet, storageSet } from '../storage' import type { AssetFile, AssetLibrary, ShadowedAsset } from '../types' - import WorkspacePicker from '../WorkspacePicker.svelte' import { workspace } from '../workspace.svelte' import { formatBytes, formatMtime } from '../format' type Origin = AssetFile['origin'] + let { shared = false }: { shared?: boolean } = $props() + const COLLAPSE_KEY = 'collapsed-asset-libraries' // What each library is called in the page's own voice. `common` is the // shared library and `examples` a read-only tree an --examples-dir @@ -125,6 +126,7 @@ (s) => s.origin === library.origin && matches(s.name), ), })) + .filter((section) => !shared || section.origin !== 'workspace') .filter( (section) => !filterActive || @@ -263,8 +265,7 @@ />
-

Assets

- +

{shared ? 'Shared assets' : 'Assets'}

{assets.length} files + +
+ +
+ + diff --git a/ui/src/lib/pages/WorkflowPage.svelte b/ui/src/lib/pages/WorkflowPage.svelte index 3e70b6f0..72f620cb 100644 --- a/ui/src/lib/pages/WorkflowPage.svelte +++ b/ui/src/lib/pages/WorkflowPage.svelte @@ -4,7 +4,9 @@ import JsonEditor from '../editor/JsonEditor.svelte' import VariablesForm from '../editor/VariablesForm.svelte' import { api } from '../api' - import { go } from '../router.svelte' + import { goWs, route } from '../router.svelte' + import { sharedHref, wsHref } from '../routes' + import { workspace } from '../workspace.svelte' import { loadPromptLibrary, promptLibrary } from '../promptlib.svelte' import { PROMPT_LIST_ID } from '../prompts' import { notify } from '../toast' @@ -74,7 +76,7 @@ function newFrom() { if (!workflow) return sessionStorage.setItem('dw-editor-import', JSON.stringify(workflow)) - go('edit') + goWs('edit') } async function remove() { @@ -89,7 +91,7 @@ return try { await api.deleteWorkflow(name) - go('workflows') + goWs('workflows') } catch (e) { const msg = e instanceof Error ? e.message : String(e) notify.error(msg) @@ -110,7 +112,7 @@ workflow_path: name, arguments: args, }) - go('jobs', job.id) + goWs('jobs', job.id) } catch (e) { const msg = e instanceof Error ? e.message : String(e) notify.error(msg) @@ -120,7 +122,12 @@ } -← workflows +← workflows