From 912799956c6a6bd91b1cd0c893f5858fba1f9430 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 21 Sep 2026 09:59:55 -0500 Subject: [PATCH 01/20] docs(ui): workspace-centric web UI design spec Co-Authored-By: Claude Opus 5 --- .../2026-09-21-workspace-centric-ui-design.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-21-workspace-centric-ui-design.md diff --git a/docs/superpowers/specs/2026-09-21-workspace-centric-ui-design.md b/docs/superpowers/specs/2026-09-21-workspace-centric-ui-design.md new file mode 100644 index 00000000..22cef30a --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-workspace-centric-ui-design.md @@ -0,0 +1,221 @@ +# Workspace-centric web UI + +**Date:** 2026-09-21 +**Status:** approved design, awaiting plan + +## Problem + +The web UI has every piece a workspace holds - workflows, jobs, gallery, +assets, editor - but nothing shows how they relate. The workspace itself is +a ` { + if (e.key === 'Enter') { e.preventDefault(); submitAdd() } + if (e.key === 'Escape') adding = false + }} + /> + {#if addError}{addError}{/if} + + {:else} + + {/if} + {/if} + + + +
+ {#if !collapsed}Shared{/if} + {#each SHARED_ITEMS as item (item.section)} + + {#if !collapsed}{item.label}{/if} + + {/each} +
+ + +
+ {#if !collapsed}Server{/if} + {#each SERVER_ITEMS as item (item.section)} + + {#if !collapsed}{item.label}{/if} + + {/each} +
+ + + + +``` + +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 ` event.key === 'Enter' && addWorkspace()} - /> - - - {:else} -

- This server was started with individual directory overrides, so it has - one workspace and cannot create others. -

- {/if} - {#if workspaceError}

{workspaceError}

{/if} - {:else if !error}

loading server details…

{/if} diff --git a/ui/src/lib/Sidebar.svelte b/ui/src/lib/Sidebar.svelte new file mode 100644 index 00000000..73a275d8 --- /dev/null +++ b/ui/src/lib/Sidebar.svelte @@ -0,0 +1,348 @@ + + +
+ + diff --git a/ui/src/lib/Sidebar.test.ts b/ui/src/lib/Sidebar.test.ts new file mode 100644 index 00000000..2eef114c --- /dev/null +++ b/ui/src/lib/Sidebar.test.ts @@ -0,0 +1,118 @@ +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 } 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')) + // and the cached listing does not leak between cases + invalidateWorkspaces() +}) +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() +}) diff --git a/ui/src/lib/WorkspacePicker.svelte b/ui/src/lib/WorkspacePicker.svelte deleted file mode 100644 index 000b5f7e..00000000 --- a/ui/src/lib/WorkspacePicker.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - -{#if choices.length > 1} - -{/if} - - diff --git a/ui/src/lib/pages/AssetsPage.svelte b/ui/src/lib/pages/AssetsPage.svelte index 8c13aec9..5b073e9b 100644 --- a/ui/src/lib/pages/AssetsPage.svelte +++ b/ui/src/lib/pages/AssetsPage.svelte @@ -24,7 +24,6 @@ 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' @@ -264,7 +263,6 @@

Assets

- {assets.length} files + {#each crumbs as crumb, i (i)} {#if i > 0}/{/if} {crumb.label} {/each} 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 index 73a275d8..fc3a5376 100644 --- a/ui/src/lib/Sidebar.svelte +++ b/ui/src/lib/Sidebar.svelte @@ -29,8 +29,11 @@ import { createWorkspaceAndGo } from './workspaceActions' import { formatBytes } from './format' - let { collapsed, onToggle }: { collapsed: boolean; onToggle: () => void } = - $props() + let { + collapsed, + onToggle, + inert = false, + }: { collapsed: boolean; onToggle: () => void; inert?: boolean } = $props() onMount(loadWorkspaces) @@ -100,7 +103,7 @@ } -