diff --git a/.superpowers/sdd/final-review-package-v2.md b/.superpowers/sdd/final-review-package-v2.md
new file mode 100644
index 0000000..02e8b86
--- /dev/null
+++ b/.superpowers/sdd/final-review-package-v2.md
@@ -0,0 +1,1825 @@
+# Final Review Package v2
+
+## Base
+4fc9271
+
+## Head
+43d0b9f0497e10f0577907fe7dbf60a5afa47916
+
+## Commits
+43d0b9f chore: remove sdd scratch artifact
+ba4ca16 fix: resolve workbench final review findings
+6341a6b test: cover project channel workbench flow
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+95dce81 feat: add project channel workbench shell
+6c12d4b feat: add project workspace data model
+5b95668 feat: add full workbench login page
+
+## Stat
+ .superpowers/sdd/task-2-report.md | 44 +++
+ .superpowers/sdd/task-4-report.md | 67 ++++
+ .superpowers/sdd/task-5-report.md | 43 +++
+ apps/web/e2e/project-workbench.spec.ts | 50 ++-
+ apps/web/src/app/App.svelte | 77 +----
+ apps/web/src/features/auth/ServerLogin.svelte | 90 ++++-
+ apps/web/src/features/auth/ServerLogin.test.ts | 66 ++++
+ .../src/features/workbench/ChannelContent.svelte | 30 ++
+ .../src/features/workbench/ChannelContent.test.ts | 64 ++++
+ .../src/features/workbench/ObjectInspector.svelte | 37 +++
+ .../workbench/ProjectChannelSidebar.svelte | 67 ++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++
+ .../src/features/workbench/ProjectWorkbench.svelte | 50 +++
+ .../features/workbench/ProjectWorkbench.test.ts | 72 ++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 +
+ .../workbench/channels/AISessionsChannel.svelte | 27 ++
+ .../features/workbench/channels/CronChannel.svelte | 19 ++
+ .../workbench/channels/CustomLinkChannel.svelte | 25 ++
+ .../workbench/channels/InboxChannel.svelte | 22 ++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 ++
+ .../workbench/channels/OverviewChannel.svelte | 29 ++
+ .../workbench/channels/TasksChannel.svelte | 46 +++
+ apps/web/src/features/workbench/mockData.test.ts | 29 ++
+ apps/web/src/features/workbench/mockData.ts | 61 ++++
+ apps/web/src/features/workbench/types.ts | 93 ++++++
+ apps/web/src/index.css | 362 +++++++++++++++------
+ 26 files changed, 1337 insertions(+), 194 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
+new file mode 100644
+index 0000000..48a37cf
+--- /dev/null
++++ b/.superpowers/sdd/task-2-report.md
+@@ -0,0 +1,44 @@
++# Task 2 Report: Workbench Types And Project-Scoped Mock Data
++
++## What changed
++
++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
++
++## Tests
++
++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
++- Web production build: `npm run build` -> successful.
++- Formatting/diff validation: `git diff --check` -> no issues.
++
++## TDD evidence
++
++1. Created `mockData.test.ts` before production implementation.
++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
++3. Added `types.ts` and `mockData.ts` using the exact task brief values.
++4. Re-ran the targeted test and observed green: 2/2 tests passed.
++5. Ran the full Web test suite and build successfully.
++
++## Files changed
++
++- `apps/web/src/features/workbench/types.ts`
++- `apps/web/src/features/workbench/mockData.ts`
++- `apps/web/src/features/workbench/mockData.test.ts`
++- `.superpowers/sdd/task-2-report.md`
++
++## Self-review
++
++- `ChannelType` contains all seven required channel literals in the specified order.
++- `ProjectWorkspace` includes all required project-scoped collections.
++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
++- No unrelated tracked files were changed.
++
++## Concerns
++
++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
+diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
+new file mode 100644
+index 0000000..b743d8d
+--- /dev/null
++++ b/.superpowers/sdd/task-4-report.md
+@@ -0,0 +1,67 @@
++# Task 4 Report: Channel Content Templates And Object Inspector
++
++## Status
++
++Implemented and committed the Task 4 workbench channel templates and object inspector.
++
++## What Changed
++
++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
++- Made all applicable records selectable through an `Inspect
` action that supplies an `InspectorItem` to the workbench.
++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
++- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
++- Kept visual-system CSS untouched for Task 5.
++
++## TDD Evidence
++
++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
++
++## Tests
++
++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
++ - Passed: 2 test files, 4 tests.
++- `npm run build`
++ - Passed: Vite production build completed successfully.
++- `git diff --check`
++ - Passed: no whitespace errors.
++
++## Files Changed
++
++- Created `apps/web/src/features/workbench/ChannelContent.svelte`
++- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
++- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
++- Created this report: `.superpowers/sdd/task-4-report.md`
++
++## Self-Review
++
++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
++- Confirmed task inspection produces the exact task title required by the test.
++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
++- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
++
++## Concerns
++
++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
++
++## Review Fixes
++
++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
++- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
+diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
+new file mode 100644
+index 0000000..d4fc752
+--- /dev/null
++++ b/.superpowers/sdd/task-5-report.md
+@@ -0,0 +1,43 @@
++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
++
++## Status
++
++Implemented and committed the Task 5 workbench visual system.
++
++## What Changed
++
++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
++
++## Tests
++
++From `apps/web`:
++
++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
++- `npm test -- --run` passed: 6 test files, 13 tests.
++- `npm run build` passed: Vite production build completed successfully.
++
++## TDD Evidence
++
++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
++
++## Files Changed
++
++- `apps/web/src/index.css`
++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
++
++## Self-Review
++
++- CSS values and selectors follow the Task 5 brief verbatim.
++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
++- Keyboard focus is visibly indicated for buttons and inputs.
++- No React or shadcn/ui dependencies were added.
++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
++
++## Concerns
++
++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
++
++## Review Fix
++
++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..08b02ee 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,49 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
++
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
++
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
+ });
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
++ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ const rail = page.getByLabel('Project list');
++ const sidebar = page.getByLabel('Project channels');
++ const stage = page.getByLabel('Channel content');
++ const inspector = page.getByLabel('Object inspector');
++
++ await expect(rail).toHaveCSS('grid-auto-flow', 'column');
++ await expect(rail).toHaveCSS('max-height', '76px');
++ await expect(inspector).toHaveCSS('max-height', '288px');
++
++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
+ });
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,19 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..be882aa 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,84 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..96887df
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,66 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++
++ it.each([
++ ['', 'Enter a server address.'],
++ ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
++ ])('rejects an invalid server value: %s', async (server, error) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText(error)).toBeInTheDocument();
++ expect(onLogin).not.toHaveBeenCalled();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..9e81962
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,30 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..63b0dd7
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,64 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders a distinctive heading or control for every channel type', () => {
++ const expectedContent = {
++ overview: { role: 'heading', name: 'Overview' },
++ inbox: { role: 'heading', name: 'Inbox' },
++ tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
++ ai_sessions: { role: 'heading', name: 'AI Sessions' },
++ notes_sources: { role: 'heading', name: 'Notes & Sources' },
++ cron: { role: 'heading', name: 'Cron Plans' },
++ custom_link: { role: 'link', name: 'Open external channel' },
++ } as const;
++
++ for (const type of Object.keys(expectedContent) as Array) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
++ unmount();
++ }
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++
++ it('toggles a task completion state accessibly', async () => {
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++
++ const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ expect(completion).not.toBeChecked();
++
++ await fireEvent.click(completion);
++
++ expect(completion).toBeChecked();
++ expect(within(completion.closest('article') as HTMLElement).getByText('Completed')).toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..79fa781
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,37 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..251682b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,67 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..495bffc
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..cead392
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,50 @@
++
++
++
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..56d2c0b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,72 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
++
++ it('clears the inspector when the project or channel changes', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..7b37384
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..0457feb
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,25 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++ {#if copyStatus}
++ {copyStatus}
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..d927c04
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,46 @@
++
++
++
++
++
++ {#each taskStates as task}
++
++ toggleTask(task.id)}
++ />
++
++
{task.title}
++
{task.summary}
++
{task.owner} / {task.due} / #{task.tag}
++
{task.completed ? 'Completed' : 'Open'}
++
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..0c52cc9 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,325 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
+ }
+
+-* {
+- box-sizing: border-box;
+-}
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++button:disabled { cursor: not-allowed; opacity: 0.55; }
+
+-body {
+- margin: 0;
+- min-width: 320px;
++.login-page {
+ min-height: 100vh;
++ display: grid;
++ place-items: center;
++ padding: 24px;
++ background: var(--background);
+ }
+
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 24px;
+ }
+
+-.shell {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
++ gap: 12px;
+ }
+
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
+- padding: 24px;
++.server-login input,
++.search-box input {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: 6px;
++ padding: 9px 10px;
++ background: white;
++}
++
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
++ border-radius: 6px;
++ background: var(--primary);
++ color: white;
++ padding: 9px 12px;
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
++.connection-status,
++.login-note,
++.form-error,
++.channel-header p,
++.channel-page small,
++.channel-page > p,
++.object-inspector > p {
++ margin: 0;
++ color: var(--muted);
++ font-size: 0.875rem;
+ }
+
+-.workspace {
++.form-error { color: var(--danger); }
++
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- gap: 24px;
+- padding: 24px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.server-login {
++.brand-mark { font-weight: 700; }
++.search-box { display: grid; gap: 4px; min-width: 0; }
++.search-box span { color: var(--muted); font-size: 0.75rem; }
++.topbar-actions { display: flex; gap: 8px; }
++.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
++
++.workbench-body {
++ min-height: 0;
+ display: grid;
+- gap: 8px;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.server-login input {
+- width: 100%;
+- border: 1px solid #c7ccd4;
+- border-radius: 6px;
+- padding: 9px 10px;
++.project-rail {
++ display: grid;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
+ }
+
+-.dashboard-grid {
+- display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
++.project-rail small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
+ padding: 16px;
++ overflow: auto;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.channel-group,
++.task-list,
++.record-list,
++.message-list,
++.file-list,
++.cron-list {
++ display: grid;
++ gap: 8px;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.channel-group button,
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row,
++.conversation-summary,
++.metric-card {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
+ }
+
+-.inbox-review {
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row {
+ display: grid;
++ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 12px;
++ align-items: start;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestions {
+- display: grid;
+- gap: 10px;
+-}
++.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
++.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
+
+-.suggestion {
+- display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
+- align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
++.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
++.channel-sidebar header p,
++.channel-sidebar h2,
++.channel-sidebar h3 { margin: 0; }
++.channel-sidebar h2 { font-size: 1rem; }
++.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
++.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.tag-row,
++.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
++.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
++.channel-group small { color: var(--muted); }
++.channel-icon { color: var(--muted); text-align: center; }
++.recent-sessions { display: grid; gap: 8px; }
++.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
++.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-stage { background: var(--background); }
++.channel-page { align-content: start; }
++.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
++.channel-header h1,
++.channel-page h2 { margin: 0; }
++.channel-header h1 { font-size: 1.25rem; }
++.channel-page h2 { font-size: 0.9375rem; }
++.channel-page h2 + p { margin: 4px 0; }
++.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
++.metric-card { display: grid; gap: 4px; text-align: left; }
++.metric-card strong { font-size: 1.5rem; }
++.metric-card span { color: var(--muted); font-size: 0.8125rem; }
++.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
++.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
++.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
++.message-row p,
++.cron-row p,
++.conversation-summary p { margin: 4px 0; }
++.message-row button,
++.file-row button,
++.task-card > button,
++.cron-row button,
++.conversation-summary button,
++.copy-url-button,
++.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
++.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
++.session-list { display: grid; align-content: start; gap: 6px; }
++.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
++.session-list button.active { border-color: var(--primary); background: #eff6ff; }
++.session-list small { color: var(--muted); }
++.conversation-summary { align-content: start; }
++.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
++.copy-url-button { width: max-content; }
++.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
++
++.object-inspector {
++ align-content: start;
++ grid-auto-rows: min-content;
+ }
+
+-.suggestion-body {
++.inspector-tabs {
+ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ align-content: start;
++ grid-auto-rows: min-content;
+ gap: 4px;
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
+-}
++@media (max-width: 920px) {
++ .workspace-topbar {
++ grid-template-columns: minmax(0, 1fr) auto;
++ height: auto;
++ padding: 12px;
++ }
++
++ .search-box { grid-column: 1 / -1; grid-row: 2; }
++ .topbar-actions { grid-column: 2; grid-row: 1; }
+
+-@media (max-width: 760px) {
+- .shell {
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
++ }
++
++ .workbench-body {
+ grid-template-columns: 1fr;
++ align-content: start;
+ }
+
+- .sidebar {
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
+ border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ border-bottom: 1px solid var(--border);
++ }
++
++ .project-rail {
++ grid-template-columns: none;
++ grid-auto-flow: column;
++ grid-auto-columns: 48px;
++ align-items: center;
++ max-height: 76px;
++ overflow-x: auto;
++ overflow-y: hidden;
++ padding: 10px 12px;
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .project-rail button { min-width: 48px; min-height: 48px; }
++
++ .channel-sidebar {
++ align-content: start;
++ max-height: 220px;
++ padding: 12px;
++ }
++
++ .channel-group,
++ .tag-row,
++ .recent-sessions ul {
++ grid-auto-flow: column;
++ grid-auto-columns: minmax(132px, max-content);
++ grid-template-columns: none;
++ flex-wrap: nowrap;
++ overflow-x: auto;
++ padding-bottom: 2px;
+ }
++
++ .channel-group button { min-width: 160px; }
++ .recent-sessions li { min-width: 160px; }
++ .channel-stage { max-height: 36vh; overflow: auto; }
++ .object-inspector { max-height: 288px; overflow: auto; }
++ .session-layout { grid-template-columns: 1fr; }
+ }
diff --git a/.superpowers/sdd/final-review-package-v3.md b/.superpowers/sdd/final-review-package-v3.md
new file mode 100644
index 0000000..797cb55
--- /dev/null
+++ b/.superpowers/sdd/final-review-package-v3.md
@@ -0,0 +1,1952 @@
+# Final Review Package v3
+
+## Base
+4fc9271
+
+## Head
+e289b1dbb0998d749bcbfb9746c53a8827515154
+
+## Commits
+e289b1d chore: remove final sdd scratch artifact
+27ff659 fix: resolve final workbench review findings
+43d0b9f chore: remove sdd scratch artifact
+ba4ca16 fix: resolve workbench final review findings
+6341a6b test: cover project channel workbench flow
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+95dce81 feat: add project channel workbench shell
+6c12d4b feat: add project workspace data model
+5b95668 feat: add full workbench login page
+
+## Stat
+ .superpowers/sdd/task-2-report.md | 44 +++
+ .superpowers/sdd/task-4-report.md | 67 ++++
+ .superpowers/sdd/task-5-report.md | 43 +++
+ apps/web/e2e/project-workbench.spec.ts | 68 +++-
+ apps/web/src/app/App.svelte | 77 +---
+ apps/web/src/features/auth/ServerLogin.svelte | 112 +++++-
+ apps/web/src/features/auth/ServerLogin.test.ts | 72 ++++
+ .../src/features/workbench/ChannelContent.svelte | 32 ++
+ .../src/features/workbench/ChannelContent.test.ts | 64 ++++
+ .../src/features/workbench/ObjectInspector.svelte | 53 +++
+ .../workbench/ProjectChannelSidebar.svelte | 67 ++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++
+ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++
+ .../features/workbench/ProjectWorkbench.test.ts | 102 ++++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 +
+ .../workbench/channels/AISessionsChannel.svelte | 27 ++
+ .../features/workbench/channels/CronChannel.svelte | 19 +
+ .../workbench/channels/CustomLinkChannel.svelte | 25 ++
+ .../workbench/channels/InboxChannel.svelte | 22 ++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 +
+ .../workbench/channels/OverviewChannel.svelte | 29 ++
+ .../workbench/channels/TasksChannel.svelte | 36 ++
+ apps/web/src/features/workbench/mockData.test.ts | 29 ++
+ apps/web/src/features/workbench/mockData.ts | 61 ++++
+ apps/web/src/features/workbench/types.ts | 93 +++++
+ apps/web/src/index.css | 391 +++++++++++++++------
+ 26 files changed, 1462 insertions(+), 193 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
+new file mode 100644
+index 0000000..48a37cf
+--- /dev/null
++++ b/.superpowers/sdd/task-2-report.md
+@@ -0,0 +1,44 @@
++# Task 2 Report: Workbench Types And Project-Scoped Mock Data
++
++## What changed
++
++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
++
++## Tests
++
++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
++- Web production build: `npm run build` -> successful.
++- Formatting/diff validation: `git diff --check` -> no issues.
++
++## TDD evidence
++
++1. Created `mockData.test.ts` before production implementation.
++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
++3. Added `types.ts` and `mockData.ts` using the exact task brief values.
++4. Re-ran the targeted test and observed green: 2/2 tests passed.
++5. Ran the full Web test suite and build successfully.
++
++## Files changed
++
++- `apps/web/src/features/workbench/types.ts`
++- `apps/web/src/features/workbench/mockData.ts`
++- `apps/web/src/features/workbench/mockData.test.ts`
++- `.superpowers/sdd/task-2-report.md`
++
++## Self-review
++
++- `ChannelType` contains all seven required channel literals in the specified order.
++- `ProjectWorkspace` includes all required project-scoped collections.
++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
++- No unrelated tracked files were changed.
++
++## Concerns
++
++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
+diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
+new file mode 100644
+index 0000000..b743d8d
+--- /dev/null
++++ b/.superpowers/sdd/task-4-report.md
+@@ -0,0 +1,67 @@
++# Task 4 Report: Channel Content Templates And Object Inspector
++
++## Status
++
++Implemented and committed the Task 4 workbench channel templates and object inspector.
++
++## What Changed
++
++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
++- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench.
++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
++- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
++- Kept visual-system CSS untouched for Task 5.
++
++## TDD Evidence
++
++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
++
++## Tests
++
++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
++ - Passed: 2 test files, 4 tests.
++- `npm run build`
++ - Passed: Vite production build completed successfully.
++- `git diff --check`
++ - Passed: no whitespace errors.
++
++## Files Changed
++
++- Created `apps/web/src/features/workbench/ChannelContent.svelte`
++- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
++- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
++- Created this report: `.superpowers/sdd/task-4-report.md`
++
++## Self-Review
++
++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
++- Confirmed task inspection produces the exact task title required by the test.
++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
++- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
++
++## Concerns
++
++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
++
++## Review Fixes
++
++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
++- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
+diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
+new file mode 100644
+index 0000000..d4fc752
+--- /dev/null
++++ b/.superpowers/sdd/task-5-report.md
+@@ -0,0 +1,43 @@
++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
++
++## Status
++
++Implemented and committed the Task 5 workbench visual system.
++
++## What Changed
++
++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
++
++## Tests
++
++From `apps/web`:
++
++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
++- `npm test -- --run` passed: 6 test files, 13 tests.
++- `npm run build` passed: Vite production build completed successfully.
++
++## TDD Evidence
++
++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
++
++## Files Changed
++
++- `apps/web/src/index.css`
++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
++
++## Self-Review
++
++- CSS values and selectors follow the Task 5 brief verbatim.
++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
++- Keyboard focus is visibly indicated for buttons and inputs.
++- No React or shadcn/ui dependencies were added.
++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
++
++## Concerns
++
++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
++
++## Review Fix
++
++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..33818a3 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,67 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
++
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
++
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
++});
++
++test('rejects malformed server addresses', async ({ page }) => {
++ await page.goto('/');
++
++ await page.getByLabel('Server IP or domain').fill('https://user:secret@example.com');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
+ });
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
++ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ const rail = page.getByLabel('Project list');
++ const sidebar = page.getByLabel('Project channels');
++ const stage = page.getByLabel('Channel content');
++ const inspector = page.getByLabel('Object inspector');
++
++ await expect(rail).toHaveCSS('grid-auto-flow', 'column');
++ await expect(rail).toHaveCSS('max-height', '76px');
++ await expect(inspector).toHaveCSS('max-height', '288px');
++
++ const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
++ await expect(workPlan).toBeVisible();
++ await expect(workPlan).toBeInViewport();
++ await workPlan.click();
++ await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
++
++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
+ });
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,19 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..09b367d 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,106 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..73e8c4a
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,72 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++
++ it.each([
++ ['', 'Enter a server address.'],
++ ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
++ [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://', 'Enter a valid HTTP or HTTPS server address.'],
++ ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ])('rejects an invalid server value: %s', async (server, error) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText(error)).toBeInTheDocument();
++ expect(onLogin).not.toHaveBeenCalled();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..6d18e6a
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,32 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..e5887db
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,64 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders a distinctive heading or control for every channel type', () => {
++ const expectedContent = {
++ overview: { role: 'heading', name: 'Overview' },
++ inbox: { role: 'heading', name: 'Inbox' },
++ tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
++ ai_sessions: { role: 'heading', name: 'AI Sessions' },
++ notes_sources: { role: 'heading', name: 'Notes & Sources' },
++ cron: { role: 'heading', name: 'Cron Plans' },
++ custom_link: { role: 'link', name: 'Open external channel' },
++ } as const;
++
++ for (const type of Object.keys(expectedContent) as Array) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
++ unmount();
++ }
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++
++ it('reports task completion changes to its owner', async () => {
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++ const onToggleTask = vi.fn();
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
++
++ const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ expect(completion).not.toBeChecked();
++
++ await fireEvent.click(completion);
++
++ expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..9232111
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,53 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ {#if copyFeedback}
++ {copyFeedback}
++ {/if}
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..251682b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,67 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..495bffc
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..f95fa73
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,61 @@
++
++
++
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..0247755
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,102 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeDisabled();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
++
++ it('clears the inspector when the project or channel changes', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ });
++
++ it('preserves task completion when switching channels within a project', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ await fireEvent.click(task);
++ expect(task).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
++ });
++
++ it('provides Copy link feedback and disables unavailable archive actions', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
++
++ expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.');
++ expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..33f7564
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..0457feb
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,25 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++ {#if copyStatus}
++ {copyStatus}
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..8639b22
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,36 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ onToggleTask(task.id)}
++ />
++
++
{task.title}
++
{task.summary}
++
{task.owner} / {task.due} / #{task.tag}
++
{task.completed ? 'Completed' : 'Open'}
++
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..cfad6a2 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,356 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
+ }
+
+-* {
+- box-sizing: border-box;
+-}
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++button:disabled { cursor: not-allowed; opacity: 0.55; }
+
+-body {
+- margin: 0;
+- min-width: 320px;
++.login-page {
+ min-height: 100vh;
++ display: grid;
++ place-items: center;
++ padding: 24px;
++ background: var(--background);
+ }
+
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 24px;
+ }
+
+-.shell {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
++ gap: 12px;
+ }
+
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
+- padding: 24px;
++.server-login input,
++.search-box input {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: 6px;
++ padding: 9px 10px;
++ background: white;
++}
++
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
++ border-radius: 6px;
++ background: var(--primary);
++ color: white;
++ padding: 9px 12px;
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
++.connection-status,
++.login-note,
++.form-error,
++.channel-header p,
++.channel-page small,
++.channel-page > p,
++.object-inspector > p {
++ margin: 0;
++ color: var(--muted);
++ font-size: 0.875rem;
+ }
+
+-.workspace {
++.form-error { color: var(--danger); }
++
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- gap: 24px;
+- padding: 24px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.server-login {
++.brand-mark { font-weight: 700; }
++.search-box { display: grid; gap: 4px; min-width: 0; }
++.search-box span { color: var(--muted); font-size: 0.75rem; }
++.topbar-actions { display: flex; gap: 8px; }
++.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
++
++.workbench-body {
++ min-height: 0;
+ display: grid;
+- gap: 8px;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.server-login input {
+- width: 100%;
+- border: 1px solid #c7ccd4;
+- border-radius: 6px;
+- padding: 9px 10px;
++.project-rail {
++ display: grid;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
+ }
+
+-.dashboard-grid {
+- display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
++.project-rail small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
+ padding: 16px;
++ overflow: auto;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.channel-sidebar {
++ align-content: start;
++ justify-items: start;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.channel-sidebar > header,
++.channel-sidebar > .channel-group,
++.channel-sidebar > .recent-sessions {
++ width: 100%;
+ }
+
+-.inbox-review {
++.channel-group,
++.task-list,
++.record-list,
++.message-list,
++.file-list,
++.cron-list {
+ display: grid;
+- gap: 12px;
++ gap: 8px;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-group button,
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row,
++.conversation-summary,
++.metric-card {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
+ }
+
+-.suggestions {
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row {
+ display: grid;
+- gap: 10px;
++ grid-template-columns: auto minmax(0, 1fr) auto;
++ gap: 12px;
++ align-items: start;
+ }
+
+-.suggestion {
+- display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
+- align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body {
++.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
++.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
++
++.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
++.channel-sidebar header p,
++.channel-sidebar h2,
++.channel-sidebar h3 { margin: 0; }
++.channel-sidebar h2 { font-size: 1rem; }
++.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
++.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.tag-row,
++.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
++.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
++.channel-group small { color: var(--muted); }
++.channel-icon { color: var(--muted); text-align: center; }
++.recent-sessions { display: grid; gap: 8px; }
++.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
++.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-stage { background: var(--background); }
++.channel-page { align-content: start; }
++.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
++.channel-header h1,
++.channel-page h2 { margin: 0; }
++.channel-header h1 { font-size: 1.25rem; }
++.channel-page h2 { font-size: 0.9375rem; }
++.channel-page h2 + p { margin: 4px 0; }
++.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
++.metric-card { display: grid; gap: 4px; text-align: left; }
++.metric-card strong { font-size: 1.5rem; }
++.metric-card span { color: var(--muted); font-size: 0.8125rem; }
++.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
++.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
++.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
++.message-row p,
++.cron-row p,
++.conversation-summary p { margin: 4px 0; }
++.message-row button,
++.file-row button,
++.task-card > button,
++.cron-row button,
++.conversation-summary button,
++.copy-url-button,
++.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
++.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
++.session-list { display: grid; align-content: start; gap: 6px; }
++.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
++.session-list button.active { border-color: var(--primary); background: #eff6ff; }
++.session-list small { color: var(--muted); }
++.conversation-summary { align-content: start; }
++.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
++.copy-url-button { width: max-content; }
++.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
++
++.object-inspector {
++ align-content: start;
++ grid-auto-rows: min-content;
++}
++
++.inspector-tabs {
+ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ align-content: start;
++ grid-auto-rows: min-content;
+ gap: 4px;
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
+-}
++@media (max-width: 920px) {
++ .workspace-topbar {
++ grid-template-columns: minmax(0, 1fr) auto;
++ height: auto;
++ padding: 12px;
++ }
++
++ .search-box { grid-column: 1 / -1; grid-row: 2; }
++ .topbar-actions { grid-column: 2; grid-row: 1; }
+
+-@media (max-width: 760px) {
+- .shell {
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
++ }
++
++ .workbench-body {
+ grid-template-columns: 1fr;
++ align-content: start;
+ }
+
+- .sidebar {
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
+ border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ border-bottom: 1px solid var(--border);
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .project-rail {
++ grid-template-columns: none;
++ grid-auto-flow: column;
++ grid-auto-columns: 48px;
++ align-items: center;
++ max-height: 76px;
++ overflow-x: auto;
++ overflow-y: hidden;
++ padding: 10px 12px;
++ }
++
++ .project-rail button { min-width: 48px; min-height: 48px; }
++
++ .channel-sidebar {
++ align-content: start;
++ max-height: 220px;
++ padding: 12px;
+ }
++
++ .channel-group,
++ .tag-row,
++ .recent-sessions ul {
++ grid-auto-flow: column;
++ grid-auto-columns: minmax(132px, max-content);
++ grid-template-columns: none;
++ flex-wrap: nowrap;
++ overflow-x: auto;
++ padding-bottom: 2px;
++ }
++
++ .channel-group button { min-width: 160px; }
++ .channel-group {
++ grid-auto-flow: row;
++ grid-auto-columns: auto;
++ grid-template-columns: repeat(3, minmax(0, 1fr));
++ overflow: visible;
++ }
++
++ .channel-group button {
++ min-width: 0;
++ min-height: 48px;
++ grid-template-columns: 18px minmax(0, 1fr);
++ padding: 8px;
++ }
++
++ .channel-group button > span:last-of-type {
++ overflow: hidden;
++ text-overflow: ellipsis;
++ }
++
++ .channel-group small { display: none; }
++ .recent-sessions li { min-width: 160px; }
++ .channel-stage { max-height: 36vh; overflow: auto; }
++ .object-inspector { max-height: 288px; overflow: auto; }
++ .session-layout { grid-template-columns: 1fr; }
+ }
diff --git a/.superpowers/sdd/final-review-package-v4.md b/.superpowers/sdd/final-review-package-v4.md
new file mode 100644
index 0000000..1abd6ca
--- /dev/null
+++ b/.superpowers/sdd/final-review-package-v4.md
@@ -0,0 +1,2162 @@
+# Final Review Package v4
+
+## Base
+4fc9271
+
+## Head
+ec59dee6b9b34c4cae4b3179fba0128ff37bb7c0
+
+## Commits
+ec59dee chore: remove final review scratch report
+2d82705 fix: resolve final workbench re-review findings
+e289b1d chore: remove final sdd scratch artifact
+27ff659 fix: resolve final workbench review findings
+43d0b9f chore: remove sdd scratch artifact
+ba4ca16 fix: resolve workbench final review findings
+6341a6b test: cover project channel workbench flow
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+95dce81 feat: add project channel workbench shell
+6c12d4b feat: add project workspace data model
+5b95668 feat: add full workbench login page
+
+## Stat
+ .superpowers/sdd/task-2-report.md | 44 +++
+ .superpowers/sdd/task-4-report.md | 67 ++++
+ .superpowers/sdd/task-5-report.md | 43 +++
+ apps/web/e2e/project-workbench.spec.ts | 70 +++-
+ apps/web/src/app/App.svelte | 77 +---
+ apps/web/src/features/auth/ServerLogin.svelte | 136 ++++++-
+ apps/web/src/features/auth/ServerLogin.test.ts | 96 +++++
+ .../src/features/workbench/ChannelContent.svelte | 32 ++
+ .../src/features/workbench/ChannelContent.test.ts | 64 ++++
+ .../src/features/workbench/ObjectInspector.svelte | 67 ++++
+ .../src/features/workbench/ObjectInspector.test.ts | 73 ++++
+ .../workbench/ProjectChannelSidebar.svelte | 67 ++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++
+ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++
+ .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 +
+ .../workbench/channels/AISessionsChannel.svelte | 27 ++
+ .../features/workbench/channels/CronChannel.svelte | 19 +
+ .../workbench/channels/CustomLinkChannel.svelte | 34 ++
+ .../workbench/channels/CustomLinkChannel.test.ts | 36 ++
+ .../workbench/channels/InboxChannel.svelte | 22 ++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 +
+ .../workbench/channels/OverviewChannel.svelte | 29 ++
+ .../workbench/channels/TasksChannel.svelte | 36 ++
+ apps/web/src/features/workbench/mockData.test.ts | 29 ++
+ apps/web/src/features/workbench/mockData.ts | 61 ++++
+ apps/web/src/features/workbench/types.ts | 93 +++++
+ apps/web/src/index.css | 392 +++++++++++++++------
+ 28 files changed, 1656 insertions(+), 193 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
+new file mode 100644
+index 0000000..48a37cf
+--- /dev/null
++++ b/.superpowers/sdd/task-2-report.md
+@@ -0,0 +1,44 @@
++# Task 2 Report: Workbench Types And Project-Scoped Mock Data
++
++## What changed
++
++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
++
++## Tests
++
++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
++- Web production build: `npm run build` -> successful.
++- Formatting/diff validation: `git diff --check` -> no issues.
++
++## TDD evidence
++
++1. Created `mockData.test.ts` before production implementation.
++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
++3. Added `types.ts` and `mockData.ts` using the exact task brief values.
++4. Re-ran the targeted test and observed green: 2/2 tests passed.
++5. Ran the full Web test suite and build successfully.
++
++## Files changed
++
++- `apps/web/src/features/workbench/types.ts`
++- `apps/web/src/features/workbench/mockData.ts`
++- `apps/web/src/features/workbench/mockData.test.ts`
++- `.superpowers/sdd/task-2-report.md`
++
++## Self-review
++
++- `ChannelType` contains all seven required channel literals in the specified order.
++- `ProjectWorkspace` includes all required project-scoped collections.
++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
++- No unrelated tracked files were changed.
++
++## Concerns
++
++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
+diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
+new file mode 100644
+index 0000000..b743d8d
+--- /dev/null
++++ b/.superpowers/sdd/task-4-report.md
+@@ -0,0 +1,67 @@
++# Task 4 Report: Channel Content Templates And Object Inspector
++
++## Status
++
++Implemented and committed the Task 4 workbench channel templates and object inspector.
++
++## What Changed
++
++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
++- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench.
++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
++- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
++- Kept visual-system CSS untouched for Task 5.
++
++## TDD Evidence
++
++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
++
++## Tests
++
++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
++ - Passed: 2 test files, 4 tests.
++- `npm run build`
++ - Passed: Vite production build completed successfully.
++- `git diff --check`
++ - Passed: no whitespace errors.
++
++## Files Changed
++
++- Created `apps/web/src/features/workbench/ChannelContent.svelte`
++- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
++- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
++- Created this report: `.superpowers/sdd/task-4-report.md`
++
++## Self-Review
++
++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
++- Confirmed task inspection produces the exact task title required by the test.
++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
++- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
++
++## Concerns
++
++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
++
++## Review Fixes
++
++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
++- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
+diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
+new file mode 100644
+index 0000000..d4fc752
+--- /dev/null
++++ b/.superpowers/sdd/task-5-report.md
+@@ -0,0 +1,43 @@
++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
++
++## Status
++
++Implemented and committed the Task 5 workbench visual system.
++
++## What Changed
++
++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
++
++## Tests
++
++From `apps/web`:
++
++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
++- `npm test -- --run` passed: 6 test files, 13 tests.
++- `npm run build` passed: Vite production build completed successfully.
++
++## TDD Evidence
++
++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
++
++## Files Changed
++
++- `apps/web/src/index.css`
++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
++
++## Self-Review
++
++- CSS values and selectors follow the Task 5 brief verbatim.
++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
++- Keyboard focus is visibly indicated for buttons and inputs.
++- No React or shadcn/ui dependencies were added.
++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
++
++## Concerns
++
++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
++
++## Review Fix
++
++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..55f6ac3 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,69 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
++
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
++
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
+ });
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
++for (const server of ['https://user:secret@example.com', 'http:////example.com', 'http://example..com', 'http://-bad.com']) {
++ test(`rejects malformed server address: ${server}`, async ({ page }) => {
++ await page.goto('/');
++
++ await page.getByLabel('Server IP or domain').fill(server);
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ });
++}
++
++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
++ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ const rail = page.getByLabel('Project list');
++ const sidebar = page.getByLabel('Project channels');
++ const stage = page.getByLabel('Channel content');
++ const inspector = page.getByLabel('Object inspector');
++
++ await expect(rail).toHaveCSS('grid-auto-flow', 'column');
++ await expect(rail).toHaveCSS('max-height', '76px');
++ await expect(inspector).toHaveCSS('max-height', '288px');
++
++ const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
++ await expect(workPlan).toBeVisible();
++ await expect(workPlan).toBeInViewport();
++ await workPlan.click();
++ await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
++
++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
+ });
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,19 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..32cc241 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,130 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..0343ba7
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,96 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it.each([
++ ['localhost', 'http://localhost'],
++ ['localhost:8080', 'http://localhost:8080'],
++ ['192.168.1.20:3000', 'http://192.168.1.20:3000'],
++ ['example.com:8443', 'http://example.com:8443'],
++ ['https://example.com', 'https://example.com'],
++ ])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++
++ it.each([
++ ['', 'Enter a server address.'],
++ ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
++ [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://', 'Enter a valid HTTP or HTTPS server address.'],
++ ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ])('rejects an invalid server value: %s', async (server, error) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText(error)).toBeInTheDocument();
++ expect(onLogin).not.toHaveBeenCalled();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..6d18e6a
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,32 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..e5887db
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,64 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders a distinctive heading or control for every channel type', () => {
++ const expectedContent = {
++ overview: { role: 'heading', name: 'Overview' },
++ inbox: { role: 'heading', name: 'Inbox' },
++ tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
++ ai_sessions: { role: 'heading', name: 'AI Sessions' },
++ notes_sources: { role: 'heading', name: 'Notes & Sources' },
++ cron: { role: 'heading', name: 'Cron Plans' },
++ custom_link: { role: 'link', name: 'Open external channel' },
++ } as const;
++
++ for (const type of Object.keys(expectedContent) as Array) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
++ unmount();
++ }
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++
++ it('reports task completion changes to its owner', async () => {
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++ const onToggleTask = vi.fn();
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
++
++ const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ expect(completion).not.toBeChecked();
++
++ await fireEvent.click(completion);
++
++ expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..6fbe503
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,67 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ {#if copyFeedback}
++ {copyFeedback}
++ {/if}
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ObjectInspector.test.ts b/apps/web/src/features/workbench/ObjectInspector.test.ts
+new file mode 100644
+index 0000000..b586131
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.test.ts
+@@ -0,0 +1,73 @@
++import '@testing-library/jest-dom/vitest';
++import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
++import { afterEach, describe, expect, it, vi } from 'vitest';
++import ObjectInspector from './ObjectInspector.svelte';
++
++const item = {
++ title: 'Review navigation',
++ type: 'Task',
++ description: 'Check the project navigation flow.',
++ properties: [],
++};
++const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++
++function setClipboard(writeText: Clipboard['writeText']) {
++ Object.defineProperty(navigator, 'clipboard', {
++ configurable: true,
++ value: { writeText } as Clipboard,
++ });
++}
++
++afterEach(() => {
++ cleanup();
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++});
++
++describe('ObjectInspector', () => {
++ async function openMoreActions() {
++ await fireEvent.click(screen.getByRole('button', { name: 'More' }));
++ }
++
++ it('shows copy success only after clipboard writing resolves', async () => {
++ let resolveWrite: (() => void) | undefined;
++ const writeText = vi.fn(
++ () =>
++ new Promise((resolve) => {
++ resolveWrite = resolve;
++ }),
++ );
++ setClipboard(writeText);
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ expect(screen.queryByRole('status')).not.toBeInTheDocument();
++ resolveWrite?.();
++ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
++ });
++
++ it('reports a copy failure when clipboard writing rejects', async () => {
++ setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
++ });
++
++ it('reports unavailable clipboard access without showing success', async () => {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..251682b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,67 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..495bffc
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..f95fa73
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,61 @@
++
++
++
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..1fda28b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,113 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeDisabled();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
++
++ it('clears the inspector when the project or channel changes', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ });
++
++ it('preserves task completion when switching channels within a project', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ await fireEvent.click(task);
++ expect(task).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
++ });
++
++ it('provides Copy link feedback and disables unavailable archive actions', async () => {
++ const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++ Object.defineProperty(navigator, 'clipboard', {
++ configurable: true,
++ value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
++ });
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
++
++ await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
++ expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
++
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..33f7564
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..57a3ff2
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,34 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++ {#if copyStatus}
++ {copyStatus}
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
+new file mode 100644
+index 0000000..8f8e845
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
+@@ -0,0 +1,36 @@
++import '@testing-library/jest-dom/vitest';
++import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
++import { afterEach, describe, expect, it } from 'vitest';
++import CustomLinkChannel from './CustomLinkChannel.svelte';
++
++const channel = {
++ id: 'project-custom-link',
++ projectID: 1,
++ type: 'custom_link' as const,
++ title: 'Roadmap Board',
++ icon: 'link',
++ url: 'https://example.com/roadmap',
++ sortOrder: 1,
++};
++const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++
++afterEach(() => {
++ cleanup();
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++});
++
++describe('CustomLinkChannel', () => {
++ it('reports unavailable clipboard access without showing URL copied', async () => {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ render(CustomLinkChannel, { props: { channel } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
++
++ await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
++ expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..8639b22
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,36 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ onToggleTask(task.id)}
++ />
++
++
{task.title}
++
{task.summary}
++
{task.owner} / {task.due} / #{task.tag}
++
{task.completed ? 'Completed' : 'Open'}
++
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..ea3f080 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,357 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
+ }
+
+-* {
+- box-sizing: border-box;
+-}
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++button:disabled { cursor: not-allowed; opacity: 0.55; }
+
+-body {
+- margin: 0;
+- min-width: 320px;
++.login-page {
+ min-height: 100vh;
++ display: grid;
++ place-items: center;
++ padding: 24px;
++ background: var(--background);
+ }
+
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 24px;
+ }
+
+-.shell {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
++ gap: 12px;
+ }
+
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
+- padding: 24px;
++.server-login input,
++.search-box input {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: 6px;
++ padding: 9px 10px;
++ background: white;
++}
++
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
++ border-radius: 6px;
++ background: var(--primary);
++ color: white;
++ padding: 9px 12px;
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
++.connection-status,
++.login-note,
++.form-error,
++.channel-header p,
++.channel-page small,
++.channel-page > p,
++.object-inspector > p {
++ margin: 0;
++ color: var(--muted);
++ font-size: 0.875rem;
+ }
+
+-.workspace {
++.form-error { color: var(--danger); }
++
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- gap: 24px;
+- padding: 24px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.server-login {
++.brand-mark { font-weight: 700; }
++.search-box { display: grid; gap: 4px; min-width: 0; }
++.search-box span { color: var(--muted); font-size: 0.75rem; }
++.topbar-actions { display: flex; gap: 8px; }
++.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
++
++.workbench-body {
++ min-height: 0;
+ display: grid;
+- gap: 8px;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.server-login input {
+- width: 100%;
+- border: 1px solid #c7ccd4;
+- border-radius: 6px;
+- padding: 9px 10px;
++.project-rail {
++ display: grid;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
+ }
+
+-.dashboard-grid {
+- display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
++.project-rail small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
+ padding: 16px;
++ overflow: auto;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.channel-sidebar {
++ align-content: start;
++ justify-items: start;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.channel-sidebar > header,
++.channel-sidebar > .channel-group,
++.channel-sidebar > .recent-sessions {
++ width: 100%;
+ }
+
+-.inbox-review {
++.channel-group,
++.task-list,
++.record-list,
++.message-list,
++.file-list,
++.cron-list {
+ display: grid;
+- gap: 12px;
++ gap: 8px;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-group button,
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row,
++.conversation-summary,
++.metric-card {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
+ }
+
+-.suggestions {
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row {
+ display: grid;
+- gap: 10px;
++ grid-template-columns: auto minmax(0, 1fr) auto;
++ gap: 12px;
++ align-items: start;
+ }
+
+-.suggestion {
+- display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
+- align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body {
++.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
++.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
++
++.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
++.channel-sidebar header p,
++.channel-sidebar h2,
++.channel-sidebar h3 { margin: 0; }
++.channel-sidebar h2 { font-size: 1rem; }
++.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
++.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.tag-row,
++.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
++.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
++.channel-group small { color: var(--muted); }
++.channel-icon { color: var(--muted); text-align: center; }
++.recent-sessions { display: grid; gap: 8px; }
++.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
++.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-stage { background: var(--background); }
++.channel-page { align-content: start; }
++.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
++.channel-header h1,
++.channel-page h2 { margin: 0; }
++.channel-header h1 { font-size: 1.25rem; }
++.channel-page h2 { font-size: 0.9375rem; }
++.channel-page h2 + p { margin: 4px 0; }
++.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
++.metric-card { display: grid; gap: 4px; text-align: left; }
++.metric-card strong { font-size: 1.5rem; }
++.metric-card span { color: var(--muted); font-size: 0.8125rem; }
++.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
++.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
++.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
++.message-row p,
++.cron-row p,
++.conversation-summary p { margin: 4px 0; }
++.message-row button,
++.file-row button,
++.task-card > button,
++.cron-row button,
++.conversation-summary button,
++.copy-url-button,
++.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
++.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
++.session-list { display: grid; align-content: start; gap: 6px; }
++.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
++.session-list button.active { border-color: var(--primary); background: #eff6ff; }
++.session-list small { color: var(--muted); }
++.conversation-summary { align-content: start; }
++.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
++.copy-url-button { width: max-content; }
++.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
++.copy-feedback.copy-error { color: var(--danger); }
++
++.object-inspector {
++ align-content: start;
++ grid-auto-rows: min-content;
++}
++
++.inspector-tabs {
+ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ align-content: start;
++ grid-auto-rows: min-content;
+ gap: 4px;
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
+-}
++@media (max-width: 920px) {
++ .workspace-topbar {
++ grid-template-columns: minmax(0, 1fr) auto;
++ height: auto;
++ padding: 12px;
++ }
++
++ .search-box { grid-column: 1 / -1; grid-row: 2; }
++ .topbar-actions { grid-column: 2; grid-row: 1; }
+
+-@media (max-width: 760px) {
+- .shell {
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
++ }
++
++ .workbench-body {
+ grid-template-columns: 1fr;
++ align-content: start;
+ }
+
+- .sidebar {
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
+ border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ border-bottom: 1px solid var(--border);
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .project-rail {
++ grid-template-columns: none;
++ grid-auto-flow: column;
++ grid-auto-columns: 48px;
++ align-items: center;
++ max-height: 76px;
++ overflow-x: auto;
++ overflow-y: hidden;
++ padding: 10px 12px;
++ }
++
++ .project-rail button { min-width: 48px; min-height: 48px; }
++
++ .channel-sidebar {
++ align-content: start;
++ max-height: 220px;
++ padding: 12px;
+ }
++
++ .channel-group,
++ .tag-row,
++ .recent-sessions ul {
++ grid-auto-flow: column;
++ grid-auto-columns: minmax(132px, max-content);
++ grid-template-columns: none;
++ flex-wrap: nowrap;
++ overflow-x: auto;
++ padding-bottom: 2px;
++ }
++
++ .channel-group button { min-width: 160px; }
++ .channel-group {
++ grid-auto-flow: row;
++ grid-auto-columns: auto;
++ grid-template-columns: repeat(3, minmax(0, 1fr));
++ overflow: visible;
++ }
++
++ .channel-group button {
++ min-width: 0;
++ min-height: 48px;
++ grid-template-columns: 18px minmax(0, 1fr);
++ padding: 8px;
++ }
++
++ .channel-group button > span:last-of-type {
++ overflow: hidden;
++ text-overflow: ellipsis;
++ }
++
++ .channel-group small { display: none; }
++ .recent-sessions li { min-width: 160px; }
++ .channel-stage { max-height: 36vh; overflow: auto; }
++ .object-inspector { max-height: 288px; overflow: auto; }
++ .session-layout { grid-template-columns: 1fr; }
+ }
diff --git a/.superpowers/sdd/final-review-package-v5.md b/.superpowers/sdd/final-review-package-v5.md
new file mode 100644
index 0000000..527057c
--- /dev/null
+++ b/.superpowers/sdd/final-review-package-v5.md
@@ -0,0 +1,2044 @@
+# Final Review Package v5
+
+## Base
+4fc9271
+
+## Head
+1fcec3e0b5f09ef238a0596344f8d478ee1e55c4
+
+## Commits
+1fcec3e chore: remove sdd task reports
+7b1bbf1 chore: remove last sdd scratch report
+884148b fix web review validation and clipboard tests
+ec59dee chore: remove final review scratch report
+2d82705 fix: resolve final workbench re-review findings
+e289b1d chore: remove final sdd scratch artifact
+27ff659 fix: resolve final workbench review findings
+43d0b9f chore: remove sdd scratch artifact
+ba4ca16 fix: resolve workbench final review findings
+6341a6b test: cover project channel workbench flow
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+95dce81 feat: add project channel workbench shell
+6c12d4b feat: add project workspace data model
+5b95668 feat: add full workbench login page
+
+## Stat
+ apps/web/e2e/project-workbench.spec.ts | 80 ++++-
+ apps/web/src/app/App.svelte | 77 +---
+ apps/web/src/features/auth/ServerLogin.svelte | 140 +++++++-
+ apps/web/src/features/auth/ServerLogin.test.ts | 102 ++++++
+ .../src/features/workbench/ChannelContent.svelte | 32 ++
+ .../src/features/workbench/ChannelContent.test.ts | 64 ++++
+ .../src/features/workbench/ObjectInspector.svelte | 67 ++++
+ .../src/features/workbench/ObjectInspector.test.ts | 73 ++++
+ .../workbench/ProjectChannelSidebar.svelte | 67 ++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++
+ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++
+ .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 +
+ .../workbench/channels/AISessionsChannel.svelte | 27 ++
+ .../features/workbench/channels/CronChannel.svelte | 19 +
+ .../workbench/channels/CustomLinkChannel.svelte | 34 ++
+ .../workbench/channels/CustomLinkChannel.test.ts | 70 ++++
+ .../workbench/channels/InboxChannel.svelte | 22 ++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 +
+ .../workbench/channels/OverviewChannel.svelte | 29 ++
+ .../workbench/channels/TasksChannel.svelte | 36 ++
+ apps/web/src/features/workbench/mockData.test.ts | 29 ++
+ apps/web/src/features/workbench/mockData.ts | 61 ++++
+ apps/web/src/features/workbench/types.ts | 93 +++++
+ apps/web/src/index.css | 392 +++++++++++++++------
+ 25 files changed, 1556 insertions(+), 193 deletions(-)
+
+## Diff
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..1b67f8f 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,79 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
++
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
++
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
+ });
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
++for (const server of [
++ 'https://user:secret@example.com',
++ 'http:////example.com',
++ 'http//example.com',
++ 'https//example.com',
++ 'http:/example.com',
++ 'https:/example.com',
++ 'http://example.com\\path',
++ 'http://example..com',
++ 'http://-bad.com',
++]) {
++ test(`rejects malformed server address: ${server}`, async ({ page }) => {
++ await page.goto('/');
++
++ await page.getByLabel('Server IP or domain').fill(server);
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ });
++}
++
++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
++ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ const rail = page.getByLabel('Project list');
++ const sidebar = page.getByLabel('Project channels');
++ const stage = page.getByLabel('Channel content');
++ const inspector = page.getByLabel('Object inspector');
++
++ await expect(rail).toHaveCSS('grid-auto-flow', 'column');
++ await expect(rail).toHaveCSS('max-height', '76px');
++ await expect(inspector).toHaveCSS('max-height', '288px');
++
++ const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
++ await expect(workPlan).toBeVisible();
++ await expect(workPlan).toBeInViewport();
++ await workPlan.click();
++ await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
++
++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
+ });
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,19 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..147724d 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,134 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..e8a617c
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,102 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it.each([
++ ['localhost', 'http://localhost'],
++ ['localhost:8080', 'http://localhost:8080'],
++ ['192.168.1.20:3000', 'http://192.168.1.20:3000'],
++ ['example.com:8443', 'http://example.com:8443'],
++ ['https://example.com', 'https://example.com'],
++ ])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++
++ it.each([
++ ['', 'Enter a server address.'],
++ ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
++ [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://', 'Enter a valid HTTP or HTTPS server address.'],
++ ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
++ ['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
++ ])('rejects an invalid server value: %s', async (server, error) => {
++ const onLogin = vi.fn();
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
++ await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
++ await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText(error)).toBeInTheDocument();
++ expect(onLogin).not.toHaveBeenCalled();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..6d18e6a
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,32 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..e5887db
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,64 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders a distinctive heading or control for every channel type', () => {
++ const expectedContent = {
++ overview: { role: 'heading', name: 'Overview' },
++ inbox: { role: 'heading', name: 'Inbox' },
++ tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
++ ai_sessions: { role: 'heading', name: 'AI Sessions' },
++ notes_sources: { role: 'heading', name: 'Notes & Sources' },
++ cron: { role: 'heading', name: 'Cron Plans' },
++ custom_link: { role: 'link', name: 'Open external channel' },
++ } as const;
++
++ for (const type of Object.keys(expectedContent) as Array) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
++ unmount();
++ }
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++
++ it('reports task completion changes to its owner', async () => {
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++ const onToggleTask = vi.fn();
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
++
++ const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ expect(completion).not.toBeChecked();
++
++ await fireEvent.click(completion);
++
++ expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..6fbe503
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,67 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ {#if copyFeedback}
++ {copyFeedback}
++ {/if}
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ObjectInspector.test.ts b/apps/web/src/features/workbench/ObjectInspector.test.ts
+new file mode 100644
+index 0000000..b586131
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.test.ts
+@@ -0,0 +1,73 @@
++import '@testing-library/jest-dom/vitest';
++import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
++import { afterEach, describe, expect, it, vi } from 'vitest';
++import ObjectInspector from './ObjectInspector.svelte';
++
++const item = {
++ title: 'Review navigation',
++ type: 'Task',
++ description: 'Check the project navigation flow.',
++ properties: [],
++};
++const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++
++function setClipboard(writeText: Clipboard['writeText']) {
++ Object.defineProperty(navigator, 'clipboard', {
++ configurable: true,
++ value: { writeText } as Clipboard,
++ });
++}
++
++afterEach(() => {
++ cleanup();
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++});
++
++describe('ObjectInspector', () => {
++ async function openMoreActions() {
++ await fireEvent.click(screen.getByRole('button', { name: 'More' }));
++ }
++
++ it('shows copy success only after clipboard writing resolves', async () => {
++ let resolveWrite: (() => void) | undefined;
++ const writeText = vi.fn(
++ () =>
++ new Promise((resolve) => {
++ resolveWrite = resolve;
++ }),
++ );
++ setClipboard(writeText);
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ expect(screen.queryByRole('status')).not.toBeInTheDocument();
++ resolveWrite?.();
++ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
++ });
++
++ it('reports a copy failure when clipboard writing rejects', async () => {
++ setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
++ });
++
++ it('reports unavailable clipboard access without showing success', async () => {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ render(ObjectInspector, { props: { item } });
++
++ await openMoreActions();
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
++
++ expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..251682b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,67 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..495bffc
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..f95fa73
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,61 @@
++
++
++
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..1fda28b
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,113 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeDisabled();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
++
++ it('clears the inspector when the project or channel changes', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
++ });
++
++ it('preserves task completion when switching channels within a project', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
++ await fireEvent.click(task);
++ expect(task).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
++ });
++
++ it('provides Copy link feedback and disables unavailable archive actions', async () => {
++ const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++ Object.defineProperty(navigator, 'clipboard', {
++ configurable: true,
++ value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
++ });
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
++
++ await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
++ expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
++
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..33f7564
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..57a3ff2
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,34 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++ {#if copyStatus}
++ {copyStatus}
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
+new file mode 100644
+index 0000000..e968295
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
+@@ -0,0 +1,70 @@
++import '@testing-library/jest-dom/vitest';
++import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
++import { afterEach, describe, expect, it, vi } from 'vitest';
++import CustomLinkChannel from './CustomLinkChannel.svelte';
++
++const channel = {
++ id: 'project-custom-link',
++ projectID: 1,
++ type: 'custom_link' as const,
++ title: 'Roadmap Board',
++ icon: 'link',
++ url: 'https://example.com/roadmap',
++ sortOrder: 1,
++};
++const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
++
++function setClipboard(writeText: Clipboard['writeText']) {
++ Object.defineProperty(navigator, 'clipboard', {
++ configurable: true,
++ value: { writeText } as Clipboard,
++ });
++}
++
++afterEach(() => {
++ cleanup();
++ if (originalClipboard) {
++ Object.defineProperty(navigator, 'clipboard', originalClipboard);
++ } else {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ }
++});
++
++describe('CustomLinkChannel', () => {
++ it('shows copy success only after clipboard writing resolves', async () => {
++ let resolveWrite: (() => void) | undefined;
++ const writeText = vi.fn(
++ () =>
++ new Promise((resolve) => {
++ resolveWrite = resolve;
++ }),
++ );
++ setClipboard(writeText);
++ render(CustomLinkChannel, { props: { channel } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
++
++ expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
++ resolveWrite?.();
++ await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
++ });
++
++ it('reports a copy failure when clipboard writing rejects', async () => {
++ setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
++ render(CustomLinkChannel, { props: { channel } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
++
++ await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
++ });
++
++ it('reports unavailable clipboard access without showing URL copied', async () => {
++ Reflect.deleteProperty(navigator, 'clipboard');
++ render(CustomLinkChannel, { props: { channel } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
++
++ await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
++ expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..8639b22
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,36 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ onToggleTask(task.id)}
++ />
++
++
{task.title}
++
{task.summary}
++
{task.owner} / {task.due} / #{task.tag}
++
{task.completed ? 'Completed' : 'Open'}
++
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..ea3f080 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,357 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
+ }
+
+-* {
+- box-sizing: border-box;
+-}
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++button:disabled { cursor: not-allowed; opacity: 0.55; }
+
+-body {
+- margin: 0;
+- min-width: 320px;
++.login-page {
+ min-height: 100vh;
++ display: grid;
++ place-items: center;
++ padding: 24px;
++ background: var(--background);
+ }
+
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 24px;
+ }
+
+-.shell {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
++ gap: 12px;
+ }
+
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
+- padding: 24px;
++.server-login input,
++.search-box input {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: 6px;
++ padding: 9px 10px;
++ background: white;
++}
++
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
++ border-radius: 6px;
++ background: var(--primary);
++ color: white;
++ padding: 9px 12px;
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
++.connection-status,
++.login-note,
++.form-error,
++.channel-header p,
++.channel-page small,
++.channel-page > p,
++.object-inspector > p {
++ margin: 0;
++ color: var(--muted);
++ font-size: 0.875rem;
+ }
+
+-.workspace {
++.form-error { color: var(--danger); }
++
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- gap: 24px;
+- padding: 24px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.server-login {
++.brand-mark { font-weight: 700; }
++.search-box { display: grid; gap: 4px; min-width: 0; }
++.search-box span { color: var(--muted); font-size: 0.75rem; }
++.topbar-actions { display: flex; gap: 8px; }
++.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
++
++.workbench-body {
++ min-height: 0;
+ display: grid;
+- gap: 8px;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.server-login input {
+- width: 100%;
+- border: 1px solid #c7ccd4;
+- border-radius: 6px;
+- padding: 9px 10px;
++.project-rail {
++ display: grid;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
+ }
+
+-.dashboard-grid {
+- display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
++.project-rail small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
+ padding: 16px;
++ overflow: auto;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.channel-sidebar {
++ align-content: start;
++ justify-items: start;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.channel-sidebar > header,
++.channel-sidebar > .channel-group,
++.channel-sidebar > .recent-sessions {
++ width: 100%;
+ }
+
+-.inbox-review {
++.channel-group,
++.task-list,
++.record-list,
++.message-list,
++.file-list,
++.cron-list {
+ display: grid;
+- gap: 12px;
++ gap: 8px;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-group button,
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row,
++.conversation-summary,
++.metric-card {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
+ }
+
+-.suggestions {
++.task-card,
++.record-row,
++.cron-row,
++.message-row,
++.file-row {
+ display: grid;
+- gap: 10px;
++ grid-template-columns: auto minmax(0, 1fr) auto;
++ gap: 12px;
++ align-items: start;
+ }
+
+-.suggestion {
+- display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
+- align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body {
++.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
++.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
++
++.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
++.channel-sidebar header p,
++.channel-sidebar h2,
++.channel-sidebar h3 { margin: 0; }
++.channel-sidebar h2 { font-size: 1rem; }
++.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
++.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.tag-row,
++.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
++.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
++.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
++.channel-group small { color: var(--muted); }
++.channel-icon { color: var(--muted); text-align: center; }
++.recent-sessions { display: grid; gap: 8px; }
++.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
++.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
++.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
++
++.channel-stage { background: var(--background); }
++.channel-page { align-content: start; }
++.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
++.channel-header h1,
++.channel-page h2 { margin: 0; }
++.channel-header h1 { font-size: 1.25rem; }
++.channel-page h2 { font-size: 0.9375rem; }
++.channel-page h2 + p { margin: 4px 0; }
++.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
++.metric-card { display: grid; gap: 4px; text-align: left; }
++.metric-card strong { font-size: 1.5rem; }
++.metric-card span { color: var(--muted); font-size: 0.8125rem; }
++.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
++.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
++.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
++.message-row p,
++.cron-row p,
++.conversation-summary p { margin: 4px 0; }
++.message-row button,
++.file-row button,
++.task-card > button,
++.cron-row button,
++.conversation-summary button,
++.copy-url-button,
++.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
++.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
++.session-list { display: grid; align-content: start; gap: 6px; }
++.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
++.session-list button.active { border-color: var(--primary); background: #eff6ff; }
++.session-list small { color: var(--muted); }
++.conversation-summary { align-content: start; }
++.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
++.copy-url-button { width: max-content; }
++.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
++.copy-feedback.copy-error { color: var(--danger); }
++
++.object-inspector {
++ align-content: start;
++ grid-auto-rows: min-content;
++}
++
++.inspector-tabs {
+ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ align-content: start;
++ grid-auto-rows: min-content;
+ gap: 4px;
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
+-}
++@media (max-width: 920px) {
++ .workspace-topbar {
++ grid-template-columns: minmax(0, 1fr) auto;
++ height: auto;
++ padding: 12px;
++ }
++
++ .search-box { grid-column: 1 / -1; grid-row: 2; }
++ .topbar-actions { grid-column: 2; grid-row: 1; }
+
+-@media (max-width: 760px) {
+- .shell {
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
++ }
++
++ .workbench-body {
+ grid-template-columns: 1fr;
++ align-content: start;
+ }
+
+- .sidebar {
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
+ border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ border-bottom: 1px solid var(--border);
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .project-rail {
++ grid-template-columns: none;
++ grid-auto-flow: column;
++ grid-auto-columns: 48px;
++ align-items: center;
++ max-height: 76px;
++ overflow-x: auto;
++ overflow-y: hidden;
++ padding: 10px 12px;
++ }
++
++ .project-rail button { min-width: 48px; min-height: 48px; }
++
++ .channel-sidebar {
++ align-content: start;
++ max-height: 220px;
++ padding: 12px;
+ }
++
++ .channel-group,
++ .tag-row,
++ .recent-sessions ul {
++ grid-auto-flow: column;
++ grid-auto-columns: minmax(132px, max-content);
++ grid-template-columns: none;
++ flex-wrap: nowrap;
++ overflow-x: auto;
++ padding-bottom: 2px;
++ }
++
++ .channel-group button { min-width: 160px; }
++ .channel-group {
++ grid-auto-flow: row;
++ grid-auto-columns: auto;
++ grid-template-columns: repeat(3, minmax(0, 1fr));
++ overflow: visible;
++ }
++
++ .channel-group button {
++ min-width: 0;
++ min-height: 48px;
++ grid-template-columns: 18px minmax(0, 1fr);
++ padding: 8px;
++ }
++
++ .channel-group button > span:last-of-type {
++ overflow: hidden;
++ text-overflow: ellipsis;
++ }
++
++ .channel-group small { display: none; }
++ .recent-sessions li { min-width: 160px; }
++ .channel-stage { max-height: 36vh; overflow: auto; }
++ .object-inspector { max-height: 288px; overflow: auto; }
++ .session-layout { grid-template-columns: 1fr; }
+ }
diff --git a/.superpowers/sdd/final-review-package.md b/.superpowers/sdd/final-review-package.md
new file mode 100644
index 0000000..ac582f4
--- /dev/null
+++ b/.superpowers/sdd/final-review-package.md
@@ -0,0 +1,1544 @@
+# Final Review Package
+
+## Base
+4fc9271
+
+## Head
+6341a6bd8b5f79a919b31358ff2c0996b07b4f59
+
+## Commits
+6341a6b test: cover project channel workbench flow
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+95dce81 feat: add project channel workbench shell
+6c12d4b feat: add project workspace data model
+5b95668 feat: add full workbench login page
+
+## Stat
+ .superpowers/sdd/task-2-report.md | 44 ++++
+ .superpowers/sdd/task-4-report.md | 67 ++++++
+ .superpowers/sdd/task-5-report.md | 43 ++++
+ apps/web/e2e/project-workbench.spec.ts | 31 ++-
+ apps/web/src/app/App.svelte | 77 +------
+ apps/web/src/features/auth/ServerLogin.svelte | 63 ++++--
+ apps/web/src/features/auth/ServerLogin.test.ts | 49 +++++
+ .../src/features/workbench/ChannelContent.svelte | 30 +++
+ .../src/features/workbench/ChannelContent.test.ts | 39 ++++
+ .../src/features/workbench/ObjectInspector.svelte | 37 ++++
+ .../workbench/ProjectChannelSidebar.svelte | 52 +++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 +++
+ .../src/features/workbench/ProjectWorkbench.svelte | 48 ++++
+ .../features/workbench/ProjectWorkbench.test.ts | 54 +++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 ++
+ .../workbench/channels/AISessionsChannel.svelte | 27 +++
+ .../features/workbench/channels/CronChannel.svelte | 19 ++
+ .../workbench/channels/CustomLinkChannel.svelte | 16 ++
+ .../workbench/channels/InboxChannel.svelte | 22 ++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 ++
+ .../workbench/channels/OverviewChannel.svelte | 29 +++
+ .../workbench/channels/TasksChannel.svelte | 23 ++
+ apps/web/src/features/workbench/mockData.test.ts | 29 +++
+ apps/web/src/features/workbench/mockData.ts | 61 +++++
+ apps/web/src/features/workbench/types.ts | 93 ++++++++
+ apps/web/src/index.css | 245 ++++++++++++---------
+ 26 files changed, 1058 insertions(+), 201 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
+new file mode 100644
+index 0000000..48a37cf
+--- /dev/null
++++ b/.superpowers/sdd/task-2-report.md
+@@ -0,0 +1,44 @@
++# Task 2 Report: Workbench Types And Project-Scoped Mock Data
++
++## What changed
++
++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
++
++## Tests
++
++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
++- Web production build: `npm run build` -> successful.
++- Formatting/diff validation: `git diff --check` -> no issues.
++
++## TDD evidence
++
++1. Created `mockData.test.ts` before production implementation.
++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
++3. Added `types.ts` and `mockData.ts` using the exact task brief values.
++4. Re-ran the targeted test and observed green: 2/2 tests passed.
++5. Ran the full Web test suite and build successfully.
++
++## Files changed
++
++- `apps/web/src/features/workbench/types.ts`
++- `apps/web/src/features/workbench/mockData.ts`
++- `apps/web/src/features/workbench/mockData.test.ts`
++- `.superpowers/sdd/task-2-report.md`
++
++## Self-review
++
++- `ChannelType` contains all seven required channel literals in the specified order.
++- `ProjectWorkspace` includes all required project-scoped collections.
++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
++- No unrelated tracked files were changed.
++
++## Concerns
++
++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
+diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
+new file mode 100644
+index 0000000..b743d8d
+--- /dev/null
++++ b/.superpowers/sdd/task-4-report.md
+@@ -0,0 +1,67 @@
++# Task 4 Report: Channel Content Templates And Object Inspector
++
++## Status
++
++Implemented and committed the Task 4 workbench channel templates and object inspector.
++
++## What Changed
++
++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
++- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench.
++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
++- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
++- Kept visual-system CSS untouched for Task 5.
++
++## TDD Evidence
++
++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
++
++## Tests
++
++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
++ - Passed: 2 test files, 4 tests.
++- `npm run build`
++ - Passed: Vite production build completed successfully.
++- `git diff --check`
++ - Passed: no whitespace errors.
++
++## Files Changed
++
++- Created `apps/web/src/features/workbench/ChannelContent.svelte`
++- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
++- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
++- Created this report: `.superpowers/sdd/task-4-report.md`
++
++## Self-Review
++
++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
++- Confirmed task inspection produces the exact task title required by the test.
++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
++- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
++
++## Concerns
++
++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
++
++## Review Fixes
++
++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
++- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
+diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
+new file mode 100644
+index 0000000..d4fc752
+--- /dev/null
++++ b/.superpowers/sdd/task-5-report.md
+@@ -0,0 +1,43 @@
++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
++
++## Status
++
++Implemented and committed the Task 5 workbench visual system.
++
++## What Changed
++
++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
++
++## Tests
++
++From `apps/web`:
++
++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
++- `npm test -- --run` passed: 6 test files, 13 tests.
++- `npm run build` passed: Vite production build completed successfully.
++
++## TDD Evidence
++
++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
++
++## Files Changed
++
++- `apps/web/src/index.css`
++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
++
++## Self-Review
++
++- CSS values and selectors follow the Task 5 brief verbatim.
++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
++- Keyboard focus is visibly indicated for buttons and inputs.
++- No React or shadcn/ui dependencies were added.
++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
++
++## Concerns
++
++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
++
++## Review Fix
++
++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..b1fee8b 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,26 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
+-});
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
+- await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
+ });
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,19 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..8e16cb9 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,63 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..7298b4b
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,49 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..9e81962
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,30 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..41c4368
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,39 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders different templates for system and custom channels', () => {
++ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ }
++
++ expect(screen.getByText('Open external channel')).toBeInTheDocument();
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..79fa781
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,37 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..40cb1a0
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,52 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..d1be341
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..3d29c30
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,48 @@
++
++
++
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..f2b6e72
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,54 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..c828fcf
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..0fa9111
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,16 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..b59e47e
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,23 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ {task.completed ? 'Done' : 'Open'}
++ {task.title} {task.summary}
{task.owner} · {task.due} · #{task.tag}
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..160edde 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,192 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+-}
+-
+-* {
+- box-sizing: border-box;
+-}
+-
+-body {
+- margin: 0;
+- min-width: 320px;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
++}
++
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++
++.login-page {
+ min-height: 100vh;
+-}
+-
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
+-}
+-
+-.shell {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
+-}
+-
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
++ place-items: center;
+ padding: 24px;
++ background: var(--background);
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
+-}
+-
+-.workspace {
+- display: grid;
+- gap: 24px;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
+ padding: 24px;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
+-}
+-
+-.server-login {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- gap: 8px;
+-}
+-
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++ gap: 12px;
+ }
+
+-.server-login input {
++.server-login input,
++.search-box input {
+ width: 100%;
+- border: 1px solid #c7ccd4;
++ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 9px 10px;
++ background: white;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
+ border-radius: 6px;
+- background: #1f2937;
++ background: var(--primary);
+ color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++ padding: 9px 12px;
+ }
+
+-.dashboard-grid {
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 16px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.workbench-body {
++ min-height: 0;
++ display: grid;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.inbox-review {
++.project-rail {
+ display: grid;
+- gap: 12px;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
++}
++
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
++}
++
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
++ padding: 16px;
++ overflow: auto;
+ }
+
+-.suggestions {
++.channel-group,
++.recent-sessions,
++.task-list,
++.record-list {
+ display: grid;
+- gap: 10px;
++ gap: 8px;
+ }
+
+-.suggestion {
++.channel-group button,
++.recent-sessions button,
++.task-card,
++.record-row,
++.cron-row {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
++}
++
++.task-card,
++.record-row,
++.cron-row {
+ display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
++ grid-template-columns: auto 1fr auto;
++ gap: 12px;
+ align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
+ }
+
+-.suggestion-body {
+- display: grid;
+- gap: 4px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs {
++ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ gap: 4px;
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-@media (max-width: 760px) {
+- .shell {
++@media (max-width: 920px) {
++ .workspace-topbar {
+ grid-template-columns: 1fr;
++ height: auto;
++ padding: 12px;
+ }
+
+- .sidebar {
+- border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .workbench-body {
++ grid-template-columns: 1fr;
++ }
++
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
++ border-right: 0;
++ border-bottom: 1px solid var(--border);
+ }
+ }
diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md
new file mode 100644
index 0000000..9d0e02c
--- /dev/null
+++ b/.superpowers/sdd/progress.md
@@ -0,0 +1,8 @@
+# SDD Progress
+
+Task 1: complete (commits 4fc9271..5b95668, review clean)
+Task 2: complete (commits 5b95668..6c12d4b, review clean)
+Task 3: complete (commits 6c12d4b..95dce81, review clean)
+Task 4: complete (commits 95dce81..1a82a8b, review clean after fix)
+Task 5: complete (commits 1a82a8b..7086b8b, review clean after fix)
+Task 6: complete (commits 7086b8b..6341a6b, review clean)
diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md
new file mode 100644
index 0000000..1597d7c
--- /dev/null
+++ b/.superpowers/sdd/task-1-brief.md
@@ -0,0 +1,200 @@
+## Task 1: Login Page And App-Level Session
+
+**Files:**
+- Modify: `apps/web/src/features/auth/ServerLogin.svelte`
+- Modify: `apps/web/src/app/App.svelte`
+- Test: `apps/web/src/features/auth/ServerLogin.test.ts`
+
+**Interfaces:**
+- Produces: `ServerLogin` Svelte component event `login: { apiBase: string; account: string }`
+- Produces: app state fields `apiBase: string`, `currentUser: { account: string } | null`
+- Consumes: `localStorage['apiBase']`
+
+- [ ] **Step 1: Write failing login component tests**
+
+Create `apps/web/src/features/auth/ServerLogin.test.ts`:
+
+```ts
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import { describe, expect, it, vi } from 'vitest';
+import ServerLogin from './ServerLogin.svelte';
+
+describe('ServerLogin', () => {
+ it('renders server, account, and password fields', () => {
+ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
+
+ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
+ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
+ expect(screen.getByLabelText('Password')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
+ });
+
+ it('normalizes server address and emits login details', async () => {
+ const onLogin = vi.fn();
+ render(ServerLogin, {
+ props: {
+ apiBase: 'localhost:8080',
+ onLogin,
+ },
+ });
+
+ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
+ target: { value: '10.0.0.12:8080' },
+ });
+ await fireEvent.input(screen.getByLabelText('Email or username'), {
+ target: { value: 'david@example.com' },
+ });
+ await fireEvent.input(screen.getByLabelText('Password'), {
+ target: { value: 'secret' },
+ });
+ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
+
+ expect(onLogin).toHaveBeenCalledWith({
+ apiBase: 'http://10.0.0.12:8080',
+ account: 'david@example.com',
+ });
+ });
+
+ it('shows an error when account or password is missing', async () => {
+ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
+
+ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run the new test to verify it fails**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/auth/ServerLogin.test.ts
+```
+
+Expected: FAIL because `ServerLogin` does not render account/password fields and does not expose `onLogin`.
+
+- [ ] **Step 3: Replace `ServerLogin.svelte` with the complete login page**
+
+Implement `apps/web/src/features/auth/ServerLogin.svelte`:
+
+```svelte
+
+
+
+
+ SA
+
+
Private workbench
+
SenlinAI Workbench
+
+
+
+
+
+```
+
+- [ ] **Step 4: Update `App.svelte` to switch from login to workbench**
+
+Temporarily render a simple logged-in placeholder; the full workbench arrives in Task 3.
+
+```svelte
+
+
+{#if currentUser}
+
+ SenlinAI Workbench
+ Signed in as {currentUser.account}
+
+{:else}
+
+{/if}
+```
+
+- [ ] **Step 5: Run tests**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/auth/ServerLogin.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\src\features\auth\ServerLogin.svelte apps\web\src\features\auth\ServerLogin.test.ts apps\web\src\app\App.svelte
+git commit -m "feat: add full workbench login page"
+```
+
+---
+
diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md
new file mode 100644
index 0000000..3887012
--- /dev/null
+++ b/.superpowers/sdd/task-1-report.md
@@ -0,0 +1,46 @@
+# Task 1 Report: Login Page And App-Level Session
+
+## What Changed
+
+- Replaced the server-only form with the complete `ServerLogin` page.
+- Added server address, account, and password inputs with accessible labels.
+- Added server address normalization to `http://` when no protocol is supplied.
+- Added account/password validation with the required error message.
+- Added the `login` callback payload: `{ apiBase, account }`.
+- Updated `App.svelte` to read and persist `localStorage['apiBase']`, render the login page until login, and render the required logged-in workbench placeholder afterward.
+
+## Tests
+
+- `npm test -- --run src/features/auth/ServerLogin.test.ts`: 1 test file, 3 tests passed.
+- `npm test -- --run`: 3 test files, 5 tests passed.
+- `npm run build`: passed.
+- `git diff --check`: passed.
+
+## TDD Evidence
+
+1. Added `ServerLogin.test.ts` before changing production code.
+2. Ran the focused test and observed the expected RED result: all 3 tests failed because the old component did not expose the required English fields, login button, or callback.
+3. Implemented the brief's component and app-level session behavior.
+4. Re-ran the focused test and observed GREEN: all 3 tests passed.
+5. Ran the full Web test suite and production build successfully.
+
+## Files Changed
+
+- `apps/web/src/features/auth/ServerLogin.svelte`
+- `apps/web/src/features/auth/ServerLogin.test.ts`
+- `apps/web/src/app/App.svelte`
+
+## Self-Review
+
+- The implementation is limited to the files named in the task brief, plus this report file.
+- No React or shadcn/ui dependency was added.
+- The component callback and app state fields match the brief exactly.
+- Server address persistence occurs only after a valid login submission.
+- Empty account/password input is rejected without invoking the login callback.
+- The logged-in placeholder is intentionally temporary and is scoped for Task 3 replacement.
+
+## Concerns
+
+- Login is frontend-only for this task: credentials are validated for presence but no backend authentication request or token session is implemented yet.
+- Vite reports the existing repository warning that no Svelte config was found; tests and build still pass.
+- The new login markup uses the existing global CSS controls; dedicated login-page styling is not part of the task brief.
diff --git a/.superpowers/sdd/task-1-review-package.md b/.superpowers/sdd/task-1-review-package.md
new file mode 100644
index 0000000..8b8d938
--- /dev/null
+++ b/.superpowers/sdd/task-1-review-package.md
@@ -0,0 +1,242 @@
+# Review package Task 1
+
+## Commits
+5b95668 feat: add full workbench login page
+
+## Stat
+ apps/web/src/app/App.svelte | 79 ++++----------------------
+ apps/web/src/features/auth/ServerLogin.svelte | 63 +++++++++++++++-----
+ apps/web/src/features/auth/ServerLogin.test.ts | 49 ++++++++++++++++
+ 3 files changed, 110 insertions(+), 81 deletions(-)
+
+## Diff
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a4c6389..a5d8dce 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,76 +1,21 @@
+
+
+-
+-
+-
+-
+-
+-
+- AI 整理建议
+-
+- 已选择 {selectedSuggestionCount} 项
+-
+-
+-
++{#if currentUser}
++
++ SenlinAI Workbench
++ Signed in as {currentUser.account}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
+index 8e80261..8e16cb9 100644
+--- a/apps/web/src/features/auth/ServerLogin.svelte
++++ b/apps/web/src/features/auth/ServerLogin.svelte
+@@ -1,28 +1,63 @@
+
+
+-
++
++
++ SA
++
++
Private workbench
++
SenlinAI Workbench
++
++
++
++
++
+diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
+new file mode 100644
+index 0000000..7298b4b
+--- /dev/null
++++ b/apps/web/src/features/auth/ServerLogin.test.ts
+@@ -0,0 +1,49 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it, vi } from 'vitest';
++import ServerLogin from './ServerLogin.svelte';
++
++describe('ServerLogin', () => {
++ it('renders server, account, and password fields', () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
++ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
++ expect(screen.getByLabelText('Password')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
++ });
++
++ it('normalizes server address and emits login details', async () => {
++ const onLogin = vi.fn();
++ render(ServerLogin, {
++ props: {
++ apiBase: 'localhost:8080',
++ onLogin,
++ },
++ });
++
++ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
++ target: { value: '10.0.0.12:8080' },
++ });
++ await fireEvent.input(screen.getByLabelText('Email or username'), {
++ target: { value: 'david@example.com' },
++ });
++ await fireEvent.input(screen.getByLabelText('Password'), {
++ target: { value: 'secret' },
++ });
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(onLogin).toHaveBeenCalledWith({
++ apiBase: 'http://10.0.0.12:8080',
++ account: 'david@example.com',
++ });
++ });
++
++ it('shows an error when account or password is missing', async () => {
++ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
++
++ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
++ });
++});
diff --git a/.superpowers/sdd/task-2-brief.md b/.superpowers/sdd/task-2-brief.md
new file mode 100644
index 0000000..6e03842
--- /dev/null
+++ b/.superpowers/sdd/task-2-brief.md
@@ -0,0 +1,250 @@
+## Task 2: Workbench Types And Project-Scoped Mock Data
+
+**Files:**
+- Create: `apps/web/src/features/workbench/types.ts`
+- Create: `apps/web/src/features/workbench/mockData.ts`
+- Test: `apps/web/src/features/workbench/mockData.test.ts`
+
+**Interfaces:**
+- Produces: `type ChannelType = 'overview' | 'inbox' | 'tasks' | 'ai_sessions' | 'notes_sources' | 'cron' | 'custom_link'`
+- Produces: `getProjectWorkspace(projectID: number): ProjectWorkspace`
+- Produces: `workbenchProjects: WorkbenchProject[]`
+
+- [ ] **Step 1: Write failing mock data tests**
+
+Create `apps/web/src/features/workbench/mockData.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { getProjectWorkspace, workbenchProjects } from './mockData';
+
+describe('workbench mock data', () => {
+ it('provides dynamic projects', () => {
+ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
+ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
+ });
+
+ it('loads project-specific channels and counts', () => {
+ const first = getProjectWorkspace(workbenchProjects[0].id);
+ const second = getProjectWorkspace(workbenchProjects[1].id);
+
+ expect(first.project.id).not.toBe(second.project.id);
+ expect(first.channels.map((channel) => channel.type)).toEqual([
+ 'overview',
+ 'inbox',
+ 'tasks',
+ 'ai_sessions',
+ 'notes_sources',
+ 'cron',
+ 'custom_link',
+ ]);
+ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
+ title: expect.any(String),
+ url: expect.stringMatching(/^https?:\/\//),
+ });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/mockData.test.ts
+```
+
+Expected: FAIL because the files do not exist.
+
+- [ ] **Step 3: Create `types.ts`**
+
+Create `apps/web/src/features/workbench/types.ts`:
+
+```ts
+export type ChannelType =
+ | 'overview'
+ | 'inbox'
+ | 'tasks'
+ | 'ai_sessions'
+ | 'notes_sources'
+ | 'cron'
+ | 'custom_link';
+
+export type WorkbenchProject = {
+ id: number;
+ name: string;
+ description: string;
+ initials: string;
+ unreadCount: number;
+};
+
+export type WorkbenchChannel = {
+ id: string;
+ projectID: number;
+ type: ChannelType;
+ title: string;
+ icon: string;
+ count?: number;
+ url?: string;
+ sortOrder: number;
+};
+
+export type InboxMessage = {
+ id: string;
+ source: string;
+ title: string;
+ summary: string;
+ status: 'open' | 'processed' | 'archived';
+ tag: string;
+ time: string;
+};
+
+export type WorkTask = {
+ id: string;
+ title: string;
+ summary: string;
+ completed: boolean;
+ owner: string;
+ due: string;
+ tag: string;
+};
+
+export type AISessionItem = {
+ id: string;
+ title: string;
+ summary: string;
+ updatedAt: string;
+ references: string[];
+};
+
+export type NoteSourceItem = {
+ id: string;
+ kind: 'note' | 'file' | 'link';
+ title: string;
+ updatedAt: string;
+ tag: string;
+ source: string;
+};
+
+export type CronPlan = {
+ id: string;
+ title: string;
+ schedule: string;
+ nextRun: string;
+ enabled: boolean;
+ lastResult: string;
+ owner: string;
+};
+
+export type InspectorItem = {
+ title: string;
+ type: string;
+ description: string;
+ properties: Array<{ label: string; value: string }>;
+};
+
+export type ProjectWorkspace = {
+ project: WorkbenchProject;
+ channels: WorkbenchChannel[];
+ tags: string[];
+ recentSessions: AISessionItem[];
+ inbox: InboxMessage[];
+ tasks: WorkTask[];
+ aiSessions: AISessionItem[];
+ notesSources: NoteSourceItem[];
+ cronPlans: CronPlan[];
+};
+```
+
+- [ ] **Step 4: Create `mockData.ts`**
+
+Create `apps/web/src/features/workbench/mockData.ts` with two project workspaces:
+
+```ts
+import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
+
+export const workbenchProjects: WorkbenchProject[] = [
+ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
+ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
+];
+
+function systemChannels(projectID: number): WorkbenchChannel[] {
+ return [
+ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
+ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
+ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
+ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
+ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
+ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
+ {
+ id: `${projectID}-custom-roadmap`,
+ projectID,
+ type: 'custom_link',
+ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
+ icon: 'link',
+ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
+ sortOrder: 7,
+ },
+ ];
+}
+
+const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
+ project,
+ channels: systemChannels(project.id),
+ tags: ['all', 'tag1', 'UI', 'knowledge'],
+ recentSessions: [
+ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
+ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
+ ],
+ inbox: [
+ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
+ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
+ ],
+ tasks: [
+ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
+ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
+ ],
+ aiSessions: [
+ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
+ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
+ ],
+ notesSources: [
+ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
+ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
+ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
+ ],
+ cronPlans: [
+ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
+ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
+ ],
+}));
+
+export function getProjectWorkspace(projectID: number): ProjectWorkspace {
+ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
+}
+```
+
+- [ ] **Step 5: Run test**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/mockData.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\src\features\workbench\types.ts apps\web\src\features\workbench\mockData.ts apps\web\src\features\workbench\mockData.test.ts
+git commit -m "feat: add project workspace data model"
+```
+
+---
+
diff --git a/.superpowers/sdd/task-2-review-package.md b/.superpowers/sdd/task-2-review-package.md
new file mode 100644
index 0000000..0e499f8
--- /dev/null
+++ b/.superpowers/sdd/task-2-review-package.md
@@ -0,0 +1,264 @@
+# Review package Task 2
+
+## Commits
+6c12d4b feat: add project workspace data model
+
+## Stat
+ .superpowers/sdd/task-2-report.md | 44 +++++++++++
+ apps/web/src/features/workbench/mockData.test.ts | 29 ++++++++
+ apps/web/src/features/workbench/mockData.ts | 61 ++++++++++++++++
+ apps/web/src/features/workbench/types.ts | 93 ++++++++++++++++++++++++
+ 4 files changed, 227 insertions(+)
+
+## Diff
+diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
+new file mode 100644
+index 0000000..48a37cf
+--- /dev/null
++++ b/.superpowers/sdd/task-2-report.md
+@@ -0,0 +1,44 @@
++# Task 2 Report: Workbench Types And Project-Scoped Mock Data
++
++## What changed
++
++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
++
++## Tests
++
++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
++- Web production build: `npm run build` -> successful.
++- Formatting/diff validation: `git diff --check` -> no issues.
++
++## TDD evidence
++
++1. Created `mockData.test.ts` before production implementation.
++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
++3. Added `types.ts` and `mockData.ts` using the exact task brief values.
++4. Re-ran the targeted test and observed green: 2/2 tests passed.
++5. Ran the full Web test suite and build successfully.
++
++## Files changed
++
++- `apps/web/src/features/workbench/types.ts`
++- `apps/web/src/features/workbench/mockData.ts`
++- `apps/web/src/features/workbench/mockData.test.ts`
++- `.superpowers/sdd/task-2-report.md`
++
++## Self-review
++
++- `ChannelType` contains all seven required channel literals in the specified order.
++- `ProjectWorkspace` includes all required project-scoped collections.
++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
++- No unrelated tracked files were changed.
++
++## Concerns
++
++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
+diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
+new file mode 100644
+index 0000000..fdbf451
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.test.ts
+@@ -0,0 +1,29 @@
++import { describe, expect, it } from 'vitest';
++import { getProjectWorkspace, workbenchProjects } from './mockData';
++
++describe('workbench mock data', () => {
++ it('provides dynamic projects', () => {
++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
++ });
++
++ it('loads project-specific channels and counts', () => {
++ const first = getProjectWorkspace(workbenchProjects[0].id);
++ const second = getProjectWorkspace(workbenchProjects[1].id);
++
++ expect(first.project.id).not.toBe(second.project.id);
++ expect(first.channels.map((channel) => channel.type)).toEqual([
++ 'overview',
++ 'inbox',
++ 'tasks',
++ 'ai_sessions',
++ 'notes_sources',
++ 'cron',
++ 'custom_link',
++ ]);
++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
++ title: expect.any(String),
++ url: expect.stringMatching(/^https?:\/\//),
++ });
++ });
++});
+diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
+new file mode 100644
+index 0000000..90a25c2
+--- /dev/null
++++ b/apps/web/src/features/workbench/mockData.ts
+@@ -0,0 +1,61 @@
++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
++
++export const workbenchProjects: WorkbenchProject[] = [
++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
++];
++
++function systemChannels(projectID: number): WorkbenchChannel[] {
++ return [
++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
++ {
++ id: `${projectID}-custom-roadmap`,
++ projectID,
++ type: 'custom_link',
++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
++ icon: 'link',
++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
++ sortOrder: 7,
++ },
++ ];
++}
++
++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
++ project,
++ channels: systemChannels(project.id),
++ tags: ['all', 'tag1', 'UI', 'knowledge'],
++ recentSessions: [
++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
++ ],
++ inbox: [
++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
++ ],
++ tasks: [
++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
++ ],
++ aiSessions: [
++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
++ ],
++ notesSources: [
++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
++ ],
++ cronPlans: [
++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
++ ],
++}));
++
++export function getProjectWorkspace(projectID: number): ProjectWorkspace {
++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
++}
+diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
+new file mode 100644
+index 0000000..98ae0f9
+--- /dev/null
++++ b/apps/web/src/features/workbench/types.ts
+@@ -0,0 +1,93 @@
++export type ChannelType =
++ | 'overview'
++ | 'inbox'
++ | 'tasks'
++ | 'ai_sessions'
++ | 'notes_sources'
++ | 'cron'
++ | 'custom_link';
++
++export type WorkbenchProject = {
++ id: number;
++ name: string;
++ description: string;
++ initials: string;
++ unreadCount: number;
++};
++
++export type WorkbenchChannel = {
++ id: string;
++ projectID: number;
++ type: ChannelType;
++ title: string;
++ icon: string;
++ count?: number;
++ url?: string;
++ sortOrder: number;
++};
++
++export type InboxMessage = {
++ id: string;
++ source: string;
++ title: string;
++ summary: string;
++ status: 'open' | 'processed' | 'archived';
++ tag: string;
++ time: string;
++};
++
++export type WorkTask = {
++ id: string;
++ title: string;
++ summary: string;
++ completed: boolean;
++ owner: string;
++ due: string;
++ tag: string;
++};
++
++export type AISessionItem = {
++ id: string;
++ title: string;
++ summary: string;
++ updatedAt: string;
++ references: string[];
++};
++
++export type NoteSourceItem = {
++ id: string;
++ kind: 'note' | 'file' | 'link';
++ title: string;
++ updatedAt: string;
++ tag: string;
++ source: string;
++};
++
++export type CronPlan = {
++ id: string;
++ title: string;
++ schedule: string;
++ nextRun: string;
++ enabled: boolean;
++ lastResult: string;
++ owner: string;
++};
++
++export type InspectorItem = {
++ title: string;
++ type: string;
++ description: string;
++ properties: Array<{ label: string; value: string }>;
++};
++
++export type ProjectWorkspace = {
++ project: WorkbenchProject;
++ channels: WorkbenchChannel[];
++ tags: string[];
++ recentSessions: AISessionItem[];
++ inbox: InboxMessage[];
++ tasks: WorkTask[];
++ aiSessions: AISessionItem[];
++ notesSources: NoteSourceItem[];
++ cronPlans: CronPlan[];
++};
diff --git a/.superpowers/sdd/task-3-brief.md b/.superpowers/sdd/task-3-brief.md
new file mode 100644
index 0000000..93b273f
--- /dev/null
+++ b/.superpowers/sdd/task-3-brief.md
@@ -0,0 +1,263 @@
+## Task 3: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
+
+**Files:**
+- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
+- Create: `apps/web/src/features/workbench/ProjectRail.svelte`
+- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
+- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
+- Modify: `apps/web/src/app/App.svelte`
+- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
+
+**Interfaces:**
+- Consumes: `workbenchProjects: WorkbenchProject[]`
+- Consumes: `getProjectWorkspace(projectID: number): ProjectWorkspace`
+- Produces: `ProjectWorkbench` prop `currentUser: { account: string }`
+- Produces: selected project and selected channel rendered with accessible names.
+
+- [ ] **Step 1: Write failing workbench shell test**
+
+Create `apps/web/src/features/workbench/ProjectWorkbench.test.ts`:
+
+```ts
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import { describe, expect, it } from 'vitest';
+import ProjectWorkbench from './ProjectWorkbench.svelte';
+
+describe('ProjectWorkbench', () => {
+ it('renders project rail, channel sidebar, topbar, and inspector', () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
+ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
+ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
+ });
+
+ it('refreshes channels when switching projects', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
+
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
+```
+
+Expected: FAIL because workbench components do not exist.
+
+- [ ] **Step 3: Create shell components**
+
+Implement `ProjectRail.svelte`, `ProjectChannelSidebar.svelte`, and `WorkspaceTopbar.svelte` with explicit props and accessible buttons:
+
+```svelte
+
+
+
+
+ SA
+ {#each projects as project}
+ onSelectProject(project.id)}
+ >
+ {project.initials}
+ {#if project.unreadCount}
+ {project.unreadCount}
+ {/if}
+
+ {/each}
+ +
+
+```
+
+```svelte
+
+
+
+
+```
+
+```svelte
+
+
+
+
+```
+
+- [ ] **Step 4: Create `ProjectWorkbench.svelte` and wire into `App.svelte`**
+
+Create `ProjectWorkbench.svelte`:
+
+```svelte
+
+
+
+
+
+
+
+
+ {workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}
+
+
+ Select an item to inspect discussion, properties, and actions.
+
+
+
+```
+
+Modify `App.svelte` to import and render `ProjectWorkbench` when logged in:
+
+```svelte
+
+
+{#if currentUser}
+
+{:else}
+
+{/if}
+```
+
+- [ ] **Step 5: Run test**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\src\app\App.svelte apps\web\src\features\workbench
+git commit -m "feat: add project channel workbench shell"
+```
+
+---
+
diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md
new file mode 100644
index 0000000..6b0768c
--- /dev/null
+++ b/.superpowers/sdd/task-3-report.md
@@ -0,0 +1,49 @@
+# Task 3 Report: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
+
+## Status
+
+Implemented the Task 3 workbench shell and integrated it with the authenticated application state.
+
+## What Changed
+
+- Added `ProjectWorkbench.svelte` to compose the workspace topbar, project rail, project channel sidebar, channel stage, and object inspector.
+- Added `ProjectRail.svelte` with accessible project buttons, selected state, unread counts, dashboard, and create-project controls.
+- Added `ProjectChannelSidebar.svelte` with project metadata, tags, channels, selected state, and recent sessions.
+- Added `WorkspaceTopbar.svelte` with a global search input, navigation controls, and account button.
+- Replaced the signed-in placeholder in `App.svelte` with `ProjectWorkbench`.
+- Added `ProjectWorkbench.test.ts` to verify the accessible shell landmarks and project switching behavior.
+
+## TDD Evidence
+
+1. Created `ProjectWorkbench.test.ts` before the workbench components existed.
+2. Ran `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` from `apps/web`.
+3. Confirmed the expected red failure: Vite could not resolve `./ProjectWorkbench.svelte` because the component had not yet been created.
+4. Implemented the shell components and application integration.
+5. Re-ran the focused test and confirmed 2 passing tests.
+
+## Tests
+
+- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` - passed: 1 test file, 2 tests.
+- `npm run build` - passed: Vite production build completed successfully.
+- `git diff --check` - passed: no whitespace errors.
+
+## Files Changed
+
+- `apps/web/src/app/App.svelte`
+- `apps/web/src/features/workbench/ProjectWorkbench.svelte`
+- `apps/web/src/features/workbench/ProjectRail.svelte`
+- `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
+- `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
+- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
+
+## Self-Review
+
+- The implementation consumes `workbenchProjects` and `getProjectWorkspace` from the existing Task 2 model.
+- Selected project buttons expose `aria-pressed`; the sidebar, project rail, global search, and inspector expose the required accessible names.
+- Changing the project updates the workspace channels and resets the selected channel to the project's first channel.
+- No channel-specific content templates or dependencies were added.
+- Changes are limited to the task brief's application and workbench files; this report is the separately requested deliverable.
+
+## Concerns
+
+None. The existing Vite setup reports that no Svelte config is present, but the focused test and production build both pass.
diff --git a/.superpowers/sdd/task-3-review-package.md b/.superpowers/sdd/task-3-review-package.md
new file mode 100644
index 0000000..95c8c76
--- /dev/null
+++ b/.superpowers/sdd/task-3-review-package.md
@@ -0,0 +1,234 @@
+# Review package Task 3
+
+## Commits
+95dce81 feat: add project channel workbench shell
+
+## Stat
+ apps/web/src/app/App.svelte | 6 +--
+ .../workbench/ProjectChannelSidebar.svelte | 52 ++++++++++++++++++++++
+ apps/web/src/features/workbench/ProjectRail.svelte | 26 +++++++++++
+ .../src/features/workbench/ProjectWorkbench.svelte | 41 +++++++++++++++++
+ .../features/workbench/ProjectWorkbench.test.ts | 26 +++++++++++
+ .../src/features/workbench/WorkspaceTopbar.svelte | 16 +++++++
+ 6 files changed, 163 insertions(+), 4 deletions(-)
+
+## Diff
+diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
+index a5d8dce..e7170ef 100644
+--- a/apps/web/src/app/App.svelte
++++ b/apps/web/src/app/App.svelte
+@@ -1,21 +1,19 @@
+
+
+ {#if currentUser}
+-
+- SenlinAI Workbench
+- Signed in as {currentUser.account}
+-
++
+ {:else}
+
+ {/if}
+diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+new file mode 100644
+index 0000000..40cb1a0
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
+@@ -0,0 +1,52 @@
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
+new file mode 100644
+index 0000000..d1be341
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectRail.svelte
+@@ -0,0 +1,26 @@
++
++
++
++ SA
++ {#each projects as project}
++ onSelectProject(project.id)}
++ >
++ {project.initials}
++ {#if project.unreadCount}
++ {project.unreadCount}
++ {/if}
++
++ {/each}
++ +
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+new file mode 100644
+index 0000000..4ab9ffd
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -0,0 +1,41 @@
++
++
++
++
++
++
++
++
++ {workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}
++
++
++ Select an item to inspect discussion, properties, and actions.
++
++
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+new file mode 100644
+index 0000000..cf251b3
+--- /dev/null
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -0,0 +1,26 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ProjectWorkbench from './ProjectWorkbench.svelte';
++
++describe('ProjectWorkbench', () => {
++ it('renders project rail, channel sidebar, topbar, and inspector', () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
++ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
++ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
++ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
++ });
++
++ it('refreshes channels when switching projects', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
++
++ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
++ });
++});
+diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+new file mode 100644
+index 0000000..c828fcf
+--- /dev/null
++++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
+@@ -0,0 +1,16 @@
++
++
++
diff --git a/.superpowers/sdd/task-4-brief.md b/.superpowers/sdd/task-4-brief.md
new file mode 100644
index 0000000..6700a40
--- /dev/null
+++ b/.superpowers/sdd/task-4-brief.md
@@ -0,0 +1,232 @@
+## Task 4: Channel Content Templates And Object Inspector
+
+**Files:**
+- Create: `apps/web/src/features/workbench/ChannelContent.svelte`
+- Create: `apps/web/src/features/workbench/ObjectInspector.svelte`
+- Create: channel components under `apps/web/src/features/workbench/channels/`
+- Modify: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
+- Test: `apps/web/src/features/workbench/ChannelContent.test.ts`
+
+**Interfaces:**
+- Consumes: `ProjectWorkspace`
+- Consumes: selected `WorkbenchChannel`
+- Produces: `onInspect(item: InspectorItem): void`
+- Produces: per-channel headings and selectable records.
+
+- [ ] **Step 1: Write failing channel content test**
+
+Create `apps/web/src/features/workbench/ChannelContent.test.ts`:
+
+```ts
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import { describe, expect, it } from 'vitest';
+import ChannelContent from './ChannelContent.svelte';
+import { getProjectWorkspace } from './mockData';
+
+describe('ChannelContent', () => {
+ const workspace = getProjectWorkspace(1);
+
+ it('renders different templates for system and custom channels', () => {
+ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
+ const channel = workspace.channels.find((item) => item.type === type);
+ if (!channel) throw new Error(`missing ${type}`);
+
+ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
+ }
+
+ expect(screen.getByText('Open external channel')).toBeInTheDocument();
+ });
+
+ it('sends selected task details to inspector', async () => {
+ let inspectedTitle = '';
+ const channel = workspace.channels.find((item) => item.type === 'tasks');
+ if (!channel) throw new Error('missing task channel');
+
+ render(ChannelContent, {
+ props: {
+ workspace,
+ channel,
+ onInspect: (item) => {
+ inspectedTitle = item.title;
+ },
+ },
+ });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
+ expect(inspectedTitle).toBe('Confirm homepage information architecture');
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/ChannelContent.test.ts
+```
+
+Expected: FAIL because `ChannelContent.svelte` does not exist.
+
+- [ ] **Step 3: Implement `ChannelContent.svelte` dispatcher**
+
+Create a dispatcher that selects the channel component by `channel.type`:
+
+```svelte
+
+
+{#if channel.type === 'overview'}
+
+{:else if channel.type === 'inbox'}
+
+{:else if channel.type === 'tasks'}
+
+{:else if channel.type === 'ai_sessions'}
+
+{:else if channel.type === 'notes_sources'}
+
+{:else if channel.type === 'cron'}
+
+{:else}
+
+{/if}
+```
+
+- [ ] **Step 4: Implement channel components**
+
+Each channel component should render its own page template and call `onInspect` for selectable records. Example for `TasksChannel.svelte`:
+
+```svelte
+
+
+
+
+
+ {#each tasks as task}
+
+ {task.completed ? '鉁? : '鈼?}
+
+
{task.title}
+
{task.summary}
+
{task.owner} 路 {task.due} 路 #{task.tag}
+
+ inspectTask(task)}>Inspect
+
+ {/each}
+
+
+```
+
+Implement the remaining channel components with the same pattern:
+
+- `OverviewChannel.svelte`: metrics for inbox, tasks, AI sessions, notes/sources, cron plans.
+- `InboxChannel.svelte`: email-like message list with source, title, summary, status, tag, time.
+- `AISessionsChannel.svelte`: session list plus selected conversation summary area.
+- `NotesSourcesChannel.svelte`: file-manager-like list of note/file/link records.
+- `CronChannel.svelte`: scheduled task rows with enabled state, schedule, next run, last result.
+- `CustomLinkChannel.svelte`: external URL page with `Open external channel` link and copy-style button.
+
+- [ ] **Step 5: Implement `ObjectInspector.svelte` and wire it into `ProjectWorkbench.svelte`**
+
+Create `ObjectInspector.svelte`:
+
+```svelte
+
+
+
+
+ (activeTab = 'discussion')}>Discussion
+ (activeTab = 'properties')}>Properties
+ (activeTab = 'more')}>More
+
+
+ {#if item}
+ {item.title}
+ {item.description}
+ {#if activeTab === 'discussion'}
+ No comments yet. Add project discussion here later.
+ {:else if activeTab === 'properties'}
+
+ {#each item.properties as property}
+
+
{property.label}
+ {property.value}
+
+ {/each}
+
+ {:else}
+ Copy link
+ Archive
+ {/if}
+ {:else}
+ Inspector
+ Select an item to inspect discussion, properties, and actions.
+ {/if}
+
+```
+
+Update `ProjectWorkbench.svelte` so `ChannelContent` receives the selected channel and `ObjectInspector` receives selected item.
+
+- [ ] **Step 6: Run channel tests**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\src\features\workbench
+git commit -m "feat: add workbench channel templates"
+```
+
+---
+
diff --git a/.superpowers/sdd/task-4-review-package-v2.md b/.superpowers/sdd/task-4-review-package-v2.md
new file mode 100644
index 0000000..e6c7290
--- /dev/null
+++ b/.superpowers/sdd/task-4-review-package-v2.md
@@ -0,0 +1,524 @@
+# Review package Task 4 v2
+
+## Commits
+1a82a8b fix: address workbench inspector review findings
+3194cbe feat: add workbench channel templates
+
+## Stat
+ .superpowers/sdd/task-4-report.md | 67 ++++++++++++++++++++++
+ .../src/features/workbench/ChannelContent.svelte | 30 ++++++++++
+ .../src/features/workbench/ChannelContent.test.ts | 39 +++++++++++++
+ .../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++
+ .../src/features/workbench/ProjectWorkbench.svelte | 15 +++--
+ .../features/workbench/ProjectWorkbench.test.ts | 22 ++++++-
+ .../workbench/channels/AISessionsChannel.svelte | 27 +++++++++
+ .../features/workbench/channels/CronChannel.svelte | 19 ++++++
+ .../workbench/channels/CustomLinkChannel.svelte | 16 ++++++
+ .../workbench/channels/InboxChannel.svelte | 22 +++++++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 ++++++
+ .../workbench/channels/OverviewChannel.svelte | 29 ++++++++++
+ .../workbench/channels/TasksChannel.svelte | 23 ++++++++
+ 13 files changed, 360 insertions(+), 5 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
+new file mode 100644
+index 0000000..b743d8d
+--- /dev/null
++++ b/.superpowers/sdd/task-4-report.md
+@@ -0,0 +1,67 @@
++# Task 4 Report: Channel Content Templates And Object Inspector
++
++## Status
++
++Implemented and committed the Task 4 workbench channel templates and object inspector.
++
++## What Changed
++
++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
++- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench.
++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
++- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
++- Kept visual-system CSS untouched for Task 5.
++
++## TDD Evidence
++
++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
++
++## Tests
++
++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
++ - Passed: 2 test files, 4 tests.
++- `npm run build`
++ - Passed: Vite production build completed successfully.
++- `git diff --check`
++ - Passed: no whitespace errors.
++
++## Files Changed
++
++- Created `apps/web/src/features/workbench/ChannelContent.svelte`
++- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
++- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
++- Created this report: `.superpowers/sdd/task-4-report.md`
++
++## Self-Review
++
++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
++- Confirmed task inspection produces the exact task title required by the test.
++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
++- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
++
++## Concerns
++
++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
++
++## Review Fixes
++
++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
++- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..9e81962
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,30 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..41c4368
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,39 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders different templates for system and custom channels', () => {
++ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ }
++
++ expect(screen.getByText('Open external channel')).toBeInTheDocument();
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..79fa781
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,37 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+index 4ab9ffd..3d29c30 100644
+--- a/apps/web/src/features/workbench/ProjectWorkbench.svelte
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -1,41 +1,48 @@
+
+
+
+
+
+
+
+
+- {workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}
++
+
+-
+- Select an item to inspect discussion, properties, and actions.
+-
++
+
+
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+index cf251b3..61e15b1 100644
+--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -1,12 +1,12 @@
+ import '@testing-library/jest-dom/vitest';
+-import { fireEvent, render, screen } from '@testing-library/svelte';
++import { fireEvent, render, screen, within } from '@testing-library/svelte';
+ import { describe, expect, it } from 'vitest';
+ import ProjectWorkbench from './ProjectWorkbench.svelte';
+
+ describe('ProjectWorkbench', () => {
+ it('renders project rail, channel sidebar, topbar, and inspector', () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
+@@ -16,11 +16,31 @@ describe('ProjectWorkbench', () => {
+ });
+
+ it('refreshes channels when switching projects', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
+
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
+ });
++
++ it('shows inspected task details in the inspector', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++ const inspector = screen.getByLabelText('Object inspector');
++
++ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++
++ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
++
++ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
++
++ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
++ expect(within(inspector).getByText('Status')).toBeInTheDocument();
++ expect(within(inspector).getByText('Open')).toBeInTheDocument();
++ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
++ expect(within(inspector).getByText('David')).toBeInTheDocument();
++ });
+ });
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..0fa9111
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,16 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..b59e47e
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,23 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ {task.completed ? 'Done' : 'Open'}
++ {task.title} {task.summary}
{task.owner} · {task.due} · #{task.tag}
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
diff --git a/.superpowers/sdd/task-4-review-package.md b/.superpowers/sdd/task-4-review-package.md
new file mode 100644
index 0000000..dae3f6c
--- /dev/null
+++ b/.superpowers/sdd/task-4-review-package.md
@@ -0,0 +1,398 @@
+# Review package Task 4
+
+## Commits
+3194cbe feat: add workbench channel templates
+
+## Stat
+ .../src/features/workbench/ChannelContent.svelte | 30 +++++++++++++++++
+ .../src/features/workbench/ChannelContent.test.ts | 39 ++++++++++++++++++++++
+ .../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++++++++++
+ .../src/features/workbench/ProjectWorkbench.svelte | 15 ++++++---
+ .../workbench/channels/AISessionsChannel.svelte | 27 +++++++++++++++
+ .../features/workbench/channels/CronChannel.svelte | 19 +++++++++++
+ .../workbench/channels/CustomLinkChannel.svelte | 16 +++++++++
+ .../workbench/channels/InboxChannel.svelte | 22 ++++++++++++
+ .../workbench/channels/NotesSourcesChannel.svelte | 19 +++++++++++
+ .../workbench/channels/OverviewChannel.svelte | 29 ++++++++++++++++
+ .../workbench/channels/TasksChannel.svelte | 23 +++++++++++++
+ 11 files changed, 272 insertions(+), 4 deletions(-)
+
+## Diff
+diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
+new file mode 100644
+index 0000000..9e81962
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.svelte
+@@ -0,0 +1,30 @@
++
++
++{#if channel.type === 'overview'}
++
++{:else if channel.type === 'inbox'}
++
++{:else if channel.type === 'tasks'}
++
++{:else if channel.type === 'ai_sessions'}
++
++{:else if channel.type === 'notes_sources'}
++
++{:else if channel.type === 'cron'}
++
++{:else}
++
++{/if}
+diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
+new file mode 100644
+index 0000000..41c4368
+--- /dev/null
++++ b/apps/web/src/features/workbench/ChannelContent.test.ts
+@@ -0,0 +1,39 @@
++import '@testing-library/jest-dom/vitest';
++import { fireEvent, render, screen } from '@testing-library/svelte';
++import { describe, expect, it } from 'vitest';
++import ChannelContent from './ChannelContent.svelte';
++import { getProjectWorkspace } from './mockData';
++
++describe('ChannelContent', () => {
++ const workspace = getProjectWorkspace(1);
++
++ it('renders different templates for system and custom channels', () => {
++ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
++ const channel = workspace.channels.find((item) => item.type === type);
++ if (!channel) throw new Error(`missing ${type}`);
++
++ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
++ }
++
++ expect(screen.getByText('Open external channel')).toBeInTheDocument();
++ });
++
++ it('sends selected task details to inspector', async () => {
++ let inspectedTitle = '';
++ const channel = workspace.channels.find((item) => item.type === 'tasks');
++ if (!channel) throw new Error('missing task channel');
++
++ render(ChannelContent, {
++ props: {
++ workspace,
++ channel,
++ onInspect: (item) => {
++ inspectedTitle = item.title;
++ },
++ },
++ });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
++ expect(inspectedTitle).toBe('Confirm homepage information architecture');
++ });
++});
+diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
+new file mode 100644
+index 0000000..f36f387
+--- /dev/null
++++ b/apps/web/src/features/workbench/ObjectInspector.svelte
+@@ -0,0 +1,37 @@
++
++
++
++
++ (activeTab = 'discussion')}>Discussion
++ (activeTab = 'properties')}>Properties
++ (activeTab = 'more')}>More
++
++
++ {#if item}
++ {item.title}
++ {item.description}
++ {#if activeTab === 'discussion'}
++ No comments yet. Add project discussion here later.
++ {:else if activeTab === 'properties'}
++
++ {#each item.properties as property}
++
++
{property.label}
++ {property.value}
++
++ {/each}
++
++ {:else}
++ Copy link
++ Archive
++ {/if}
++ {:else}
++ Inspector
++ Select an item to inspect discussion, properties, and actions.
++ {/if}
++
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+index 4ab9ffd..3d29c30 100644
+--- a/apps/web/src/features/workbench/ProjectWorkbench.svelte
++++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
+@@ -1,41 +1,48 @@
+
+
+
+
+
+
+
+
+- {workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}
++
+
+-
+- Select an item to inspect discussion, properties, and actions.
+-
++
+
+
+diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+new file mode 100644
+index 0000000..3b52383
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
+@@ -0,0 +1,27 @@
++
++
++
++
++
++
++ {#each sessions as session}
++ (selectedSessionID = session.id)}>{session.title}{session.updatedAt}
++ {/each}
++
++ {#if selectedSession}
++
{selectedSession.title} {selectedSession.summary}
References: {selectedSession.references.join(', ')}
inspectSession(selectedSession)}>Inspect
++ {/if}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
+new file mode 100644
+index 0000000..7a6f2c3
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each plans as plan}
++
{plan.title} {plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult} inspectPlan(plan)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+new file mode 100644
+index 0000000..0fa9111
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
+@@ -0,0 +1,16 @@
++
++
++
++
++ {channel.url}
++ Open external channel
++ Copy URL
++
+diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+new file mode 100644
+index 0000000..2d25d63
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
+@@ -0,0 +1,22 @@
++
++
++
++
++
++ {#each messages as message}
++
++ {message.source} · {message.time} {message.title} {message.summary}
{message.status} · #{message.tag}
++ inspectMessage(message)}>Inspect
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+new file mode 100644
+index 0000000..256ef8b
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
+@@ -0,0 +1,19 @@
++
++
++
++
++
++ {#each items as item}
++
{item.kind}
{item.title} {item.source} · {item.updatedAt} · #{item.tag} inspectItem(item)}>Inspect
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+new file mode 100644
+index 0000000..6d478cf
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
+@@ -0,0 +1,29 @@
++
++
++
++
++
++ {#each metrics(workspace) as metric}
++ onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
++ {metric.value}
++ {metric.label}
++
++ {/each}
++
++
+diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+new file mode 100644
+index 0000000..b59e47e
+--- /dev/null
++++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
+@@ -0,0 +1,23 @@
++
++
++
++
++
++ {#each tasks as task}
++
++ {task.completed ? 'Done' : 'Open'}
++ {task.title} {task.summary}
{task.owner} · {task.due} · #{task.tag}
++ inspectTask(task)}>Inspect
++
++ {/each}
++
++
diff --git a/.superpowers/sdd/task-5-brief.md b/.superpowers/sdd/task-5-brief.md
new file mode 100644
index 0000000..1767ae6
--- /dev/null
+++ b/.superpowers/sdd/task-5-brief.md
@@ -0,0 +1,258 @@
+## Task 5: shadcn/ui-Inspired Visual System In CSS
+
+**Files:**
+- Modify: `apps/web/src/index.css`
+- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
+
+**Interfaces:**
+- Consumes: class names introduced in Tasks 1-4.
+- Produces: stable desktop layout, responsive mobile layout, accessible focus states.
+
+- [ ] **Step 1: Add a test assertion for active state semantics**
+
+Extend `ProjectWorkbench.test.ts` with:
+
+```ts
+it('marks active channel with aria-pressed', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
+
+ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
+});
+```
+
+- [ ] **Step 2: Run test**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
+```
+
+Expected: PASS before visual CSS work; this protects state semantics while styling changes.
+
+- [ ] **Step 3: Replace `index.css` with workbench visual system**
+
+Implement CSS tokens and component classes:
+
+```css
+:root {
+ color: #18181b;
+ background: #f4f4f5;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+ --background: #f4f4f5;
+ --panel: #ffffff;
+ --panel-muted: #fafafa;
+ --border: #d4d4d8;
+ --text: #18181b;
+ --muted: #71717a;
+ --primary: #0f7ae5;
+ --primary-strong: #0969c8;
+ --success: #15803d;
+ --danger: #b91c1c;
+ --radius: 8px;
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; min-height: 100vh; }
+button, input, textarea, select { font: inherit; }
+button { cursor: pointer; }
+button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
+
+.login-page {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+ background: var(--background);
+}
+
+.login-panel {
+ width: min(420px, 100%);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--panel);
+ padding: 24px;
+}
+
+.server-login,
+.login-heading,
+.channel-page,
+.object-inspector,
+.channel-sidebar {
+ display: grid;
+ gap: 12px;
+}
+
+.server-login input,
+.search-box input {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 9px 10px;
+ background: white;
+}
+
+.server-login button,
+.primary-action {
+ border: 1px solid var(--primary);
+ border-radius: 6px;
+ background: var(--primary);
+ color: white;
+ padding: 9px 12px;
+}
+
+.workbench-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-rows: 64px 1fr;
+}
+
+.workspace-topbar {
+ display: grid;
+ grid-template-columns: 160px minmax(240px, 1fr) auto;
+ align-items: center;
+ gap: 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+ padding: 0 16px;
+}
+
+.workbench-body {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+}
+
+.project-rail,
+.channel-sidebar,
+.object-inspector {
+ border-right: 1px solid var(--border);
+ background: var(--panel);
+}
+
+.project-rail {
+ display: grid;
+ align-content: start;
+ gap: 10px;
+ padding: 12px;
+}
+
+.project-rail button {
+ min-width: 48px;
+ min-height: 48px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--panel-muted);
+}
+
+.project-rail button.active,
+.channel-group button.active {
+ border-color: var(--primary);
+ color: var(--primary);
+ background: #eff6ff;
+}
+
+.channel-sidebar,
+.object-inspector,
+.channel-stage {
+ padding: 16px;
+ overflow: auto;
+}
+
+.channel-group,
+.recent-sessions,
+.task-list,
+.record-list {
+ display: grid;
+ gap: 8px;
+}
+
+.channel-group button,
+.recent-sessions button,
+.task-card,
+.record-row,
+.cron-row {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--panel);
+ padding: 10px 12px;
+}
+
+.task-card,
+.record-row,
+.cron-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 12px;
+ align-items: start;
+}
+
+.task-card.completed {
+ color: var(--muted);
+ background: var(--panel-muted);
+}
+
+.inspector-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+}
+
+.inspector-tabs button[aria-selected='true'] {
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+@media (max-width: 920px) {
+ .workspace-topbar {
+ grid-template-columns: 1fr;
+ height: auto;
+ padding: 12px;
+ }
+
+ .workbench-shell {
+ grid-template-rows: auto 1fr;
+ }
+
+ .workbench-body {
+ grid-template-columns: 1fr;
+ }
+
+ .project-rail,
+ .channel-sidebar,
+ .object-inspector {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+}
+```
+
+- [ ] **Step 4: Run component tests and build**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run
+npm run build
+```
+
+Expected: both PASS.
+
+- [ ] **Step 5: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\src\index.css apps\web\src\features\workbench\ProjectWorkbench.test.ts
+git commit -m "style: apply workbench visual system"
+```
+
+---
+
diff --git a/.superpowers/sdd/task-5-review-package-v2.md b/.superpowers/sdd/task-5-review-package-v2.md
new file mode 100644
index 0000000..bc07d00
--- /dev/null
+++ b/.superpowers/sdd/task-5-review-package-v2.md
@@ -0,0 +1,401 @@
+# Review package Task 5 v2
+
+## Commits
+7086b8b fix: style active inspector view
+092930e style: apply workbench visual system
+
+## Stat
+ .superpowers/sdd/task-5-report.md | 43 ++++
+ .../features/workbench/ProjectWorkbench.test.ts | 8 +
+ apps/web/src/index.css | 245 ++++++++++++---------
+ 3 files changed, 187 insertions(+), 109 deletions(-)
+
+## Diff
+diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
+new file mode 100644
+index 0000000..d4fc752
+--- /dev/null
++++ b/.superpowers/sdd/task-5-report.md
+@@ -0,0 +1,43 @@
++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
++
++## Status
++
++Implemented and committed the Task 5 workbench visual system.
++
++## What Changed
++
++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
++
++## Tests
++
++From `apps/web`:
++
++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
++- `npm test -- --run` passed: 6 test files, 13 tests.
++- `npm run build` passed: Vite production build completed successfully.
++
++## TDD Evidence
++
++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
++
++## Files Changed
++
++- `apps/web/src/index.css`
++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
++
++## Self-Review
++
++- CSS values and selectors follow the Task 5 brief verbatim.
++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
++- Keyboard focus is visibly indicated for buttons and inputs.
++- No React or shadcn/ui dependencies were added.
++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
++
++## Concerns
++
++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
++
++## Review Fix
++
++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+index 61e15b1..f2b6e72 100644
+--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => {
+
+ it('refreshes channels when switching projects', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
+
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
+ });
+
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
+ it('shows inspected task details in the inspector', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+ const inspector = screen.getByLabelText('Object inspector');
+
+ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
+
+ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..160edde 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,192 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+-}
+-
+-* {
+- box-sizing: border-box;
+-}
+-
+-body {
+- margin: 0;
+- min-width: 320px;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
++}
++
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++
++.login-page {
+ min-height: 100vh;
+-}
+-
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
+-}
+-
+-.shell {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
+-}
+-
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
++ place-items: center;
+ padding: 24px;
++ background: var(--background);
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
+-}
+-
+-.workspace {
+- display: grid;
+- gap: 24px;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
+ padding: 24px;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
+-}
+-
+-.server-login {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- gap: 8px;
+-}
+-
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++ gap: 12px;
+ }
+
+-.server-login input {
++.server-login input,
++.search-box input {
+ width: 100%;
+- border: 1px solid #c7ccd4;
++ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 9px 10px;
++ background: white;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
+ border-radius: 6px;
+- background: #1f2937;
++ background: var(--primary);
+ color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++ padding: 9px 12px;
+ }
+
+-.dashboard-grid {
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 16px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.workbench-body {
++ min-height: 0;
++ display: grid;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.inbox-review {
++.project-rail {
+ display: grid;
+- gap: 12px;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
++}
++
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
++}
++
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
++ padding: 16px;
++ overflow: auto;
+ }
+
+-.suggestions {
++.channel-group,
++.recent-sessions,
++.task-list,
++.record-list {
+ display: grid;
+- gap: 10px;
++ gap: 8px;
+ }
+
+-.suggestion {
++.channel-group button,
++.recent-sessions button,
++.task-card,
++.record-row,
++.cron-row {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
++}
++
++.task-card,
++.record-row,
++.cron-row {
+ display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
++ grid-template-columns: auto 1fr auto;
++ gap: 12px;
+ align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
+ }
+
+-.suggestion-body {
+- display: grid;
+- gap: 4px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs {
++ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ gap: 4px;
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
++.inspector-tabs button[aria-pressed='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-@media (max-width: 760px) {
+- .shell {
++@media (max-width: 920px) {
++ .workspace-topbar {
+ grid-template-columns: 1fr;
++ height: auto;
++ padding: 12px;
+ }
+
+- .sidebar {
+- border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .workbench-body {
++ grid-template-columns: 1fr;
++ }
++
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
++ border-right: 0;
++ border-bottom: 1px solid var(--border);
+ }
+ }
diff --git a/.superpowers/sdd/task-5-review-package.md b/.superpowers/sdd/task-5-review-package.md
new file mode 100644
index 0000000..78795ad
--- /dev/null
+++ b/.superpowers/sdd/task-5-review-package.md
@@ -0,0 +1,350 @@
+# Review package Task 5
+
+## Commits
+092930e style: apply workbench visual system
+
+## Stat
+ .../features/workbench/ProjectWorkbench.test.ts | 8 +
+ apps/web/src/index.css | 245 ++++++++++++---------
+ 2 files changed, 144 insertions(+), 109 deletions(-)
+
+## Diff
+diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+index 61e15b1..f2b6e72 100644
+--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
+@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => {
+
+ it('refreshes channels when switching projects', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
+
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
+ });
+
++ it('marks active channel with aria-pressed', async () => {
++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
++
++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
++
++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
++ });
++
+ it('shows inspected task details in the inspector', async () => {
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
+ const inspector = screen.getByLabelText('Object inspector');
+
+ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
+
+ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
+diff --git a/apps/web/src/index.css b/apps/web/src/index.css
+index 7a1e945..5a9f932 100644
+--- a/apps/web/src/index.css
++++ b/apps/web/src/index.css
+@@ -1,165 +1,192 @@
+ :root {
+- color: #202124;
+- background: #f6f7f9;
+- font-family:
+- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
++ color: #18181b;
++ background: #f4f4f5;
++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.5;
+-}
+-
+-* {
+- box-sizing: border-box;
+-}
+-
+-body {
+- margin: 0;
+- min-width: 320px;
++ --background: #f4f4f5;
++ --panel: #ffffff;
++ --panel-muted: #fafafa;
++ --border: #d4d4d8;
++ --text: #18181b;
++ --muted: #71717a;
++ --primary: #0f7ae5;
++ --primary-strong: #0969c8;
++ --success: #15803d;
++ --danger: #b91c1c;
++ --radius: 8px;
++}
++
++* { box-sizing: border-box; }
++body { margin: 0; min-width: 320px; min-height: 100vh; }
++button, input, textarea, select { font: inherit; }
++button { cursor: pointer; }
++button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
++
++.login-page {
+ min-height: 100vh;
+-}
+-
+-button,
+-input,
+-textarea,
+-select {
+- font: inherit;
+-}
+-
+-.shell {
+ display: grid;
+- grid-template-columns: 280px 1fr;
+- min-height: 100vh;
+-}
+-
+-.sidebar {
+- border-right: 1px solid #d9dde3;
+- background: #ffffff;
++ place-items: center;
+ padding: 24px;
++ background: var(--background);
+ }
+
+-.sidebar h1 {
+- margin: 0 0 24px;
+- font-size: 24px;
+-}
+-
+-.workspace {
+- display: grid;
+- gap: 24px;
++.login-panel {
++ width: min(420px, 100%);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
+ padding: 24px;
+ }
+
+-.connection-status,
+-.selection-status {
+- margin: 12px 0 0;
+- color: #5f6673;
+- font-size: 13px;
+-}
+-
+-.server-login {
++.server-login,
++.login-heading,
++.channel-page,
++.object-inspector,
++.channel-sidebar {
+ display: grid;
+- gap: 8px;
+-}
+-
+-.server-login label {
+- font-size: 13px;
+- color: #4b5563;
++ gap: 12px;
+ }
+
+-.server-login input {
++.server-login input,
++.search-box input {
+ width: 100%;
+- border: 1px solid #c7ccd4;
++ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 9px 10px;
++ background: white;
+ }
+
+-.server-login button {
+- border: 1px solid #1f2937;
++.server-login button,
++.primary-action {
++ border: 1px solid var(--primary);
+ border-radius: 6px;
+- background: #1f2937;
++ background: var(--primary);
+ color: white;
+- padding: 9px 10px;
+- cursor: pointer;
++ padding: 9px 12px;
+ }
+
+-.dashboard-grid {
++.workbench-shell {
++ min-height: 100vh;
+ display: grid;
+- grid-template-columns: repeat(4, minmax(160px, 1fr));
+- gap: 12px;
++ grid-template-rows: 64px 1fr;
+ }
+
+-.dashboard-metric {
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 16px;
++.workspace-topbar {
++ display: grid;
++ grid-template-columns: 160px minmax(240px, 1fr) auto;
++ align-items: center;
++ gap: 16px;
++ border-bottom: 1px solid var(--border);
++ background: var(--panel);
++ padding: 0 16px;
+ }
+
+-.dashboard-metric span {
+- display: block;
+- color: #5f6673;
+- font-size: 14px;
++.workbench-body {
++ min-height: 0;
++ display: grid;
++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
+ }
+
+-.dashboard-metric strong {
+- display: block;
+- margin-top: 8px;
+- font-size: 28px;
++.project-rail,
++.channel-sidebar,
++.object-inspector {
++ border-right: 1px solid var(--border);
++ background: var(--panel);
+ }
+
+-.inbox-review {
++.project-rail {
+ display: grid;
+- gap: 12px;
++ align-content: start;
++ gap: 10px;
++ padding: 12px;
++}
++
++.project-rail button {
++ min-width: 48px;
++ min-height: 48px;
++ border: 1px solid var(--border);
++ border-radius: 999px;
++ background: var(--panel-muted);
++}
++
++.project-rail button.active,
++.channel-group button.active {
++ border-color: var(--primary);
++ color: var(--primary);
++ background: #eff6ff;
+ }
+
+-.inbox-review h2 {
+- margin: 0;
+- font-size: 18px;
++.channel-sidebar,
++.object-inspector,
++.channel-stage {
++ padding: 16px;
++ overflow: auto;
+ }
+
+-.suggestions {
++.channel-group,
++.recent-sessions,
++.task-list,
++.record-list {
+ display: grid;
+- gap: 10px;
++ gap: 8px;
+ }
+
+-.suggestion {
++.channel-group button,
++.recent-sessions button,
++.task-card,
++.record-row,
++.cron-row {
++ width: 100%;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ background: var(--panel);
++ padding: 10px 12px;
++}
++
++.task-card,
++.record-row,
++.cron-row {
+ display: grid;
+- grid-template-columns: 20px 1fr;
+- gap: 10px;
++ grid-template-columns: auto 1fr auto;
++ gap: 12px;
+ align-items: start;
+- border: 1px solid #d9dde3;
+- border-radius: 8px;
+- background: white;
+- padding: 12px;
+ }
+
+-.suggestion-body {
+- display: grid;
+- gap: 4px;
++.task-card.completed {
++ color: var(--muted);
++ background: var(--panel-muted);
+ }
+
+-.suggestion-body small {
+- color: #5f6673;
++.inspector-tabs {
++ display: grid;
++ grid-template-columns: repeat(3, 1fr);
++ gap: 4px;
+ }
+
+-.suggestions button {
+- justify-self: start;
+- border: 1px solid #1f2937;
+- border-radius: 6px;
+- background: #1f2937;
+- color: white;
+- padding: 9px 12px;
+- cursor: pointer;
++.inspector-tabs button[aria-selected='true'] {
++ border-color: var(--primary);
++ color: var(--primary);
+ }
+
+-@media (max-width: 760px) {
+- .shell {
++@media (max-width: 920px) {
++ .workspace-topbar {
+ grid-template-columns: 1fr;
++ height: auto;
++ padding: 12px;
+ }
+
+- .sidebar {
+- border-right: 0;
+- border-bottom: 1px solid #d9dde3;
++ .workbench-shell {
++ grid-template-rows: auto 1fr;
+ }
+
+- .dashboard-grid {
+- grid-template-columns: repeat(2, minmax(0, 1fr));
++ .workbench-body {
++ grid-template-columns: 1fr;
++ }
++
++ .project-rail,
++ .channel-sidebar,
++ .object-inspector {
++ border-right: 0;
++ border-bottom: 1px solid var(--border);
+ }
+ }
diff --git a/.superpowers/sdd/task-6-brief.md b/.superpowers/sdd/task-6-brief.md
new file mode 100644
index 0000000..14c9381
--- /dev/null
+++ b/.superpowers/sdd/task-6-brief.md
@@ -0,0 +1,106 @@
+## Task 6: Playwright Smoke Flow
+
+**Files:**
+- Modify: `apps/web/e2e/project-workbench.spec.ts`
+
+**Interfaces:**
+- Consumes: login form labels from Task 1.
+- Consumes: project/channel labels from Tasks 2-4.
+- Produces: smoke coverage for login, dynamic project switching, channel template switching, custom URL channel.
+
+- [ ] **Step 1: Replace Playwright smoke tests**
+
+Update `apps/web/e2e/project-workbench.spec.ts`:
+
+```ts
+import { expect, test } from '@playwright/test';
+
+test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
+ await page.getByLabel('Server IP or domain').fill('localhost:8080');
+ await page.getByLabel('Email or username').fill('david@example.com');
+ await page.getByLabel('Password').fill('secret');
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ await expect(page.getByLabel('Project list')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Work Plan 8' }).click();
+ await expect(page.getByText('Tasks')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Project A2' }).click();
+ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
+ await expect(page.getByText('Ops Dashboard')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
+ await expect(page.getByText('Open external channel')).toBeVisible();
+});
+```
+
+- [ ] **Step 2: Run Playwright test**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npx playwright test
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run full web verification**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent\apps\web
+npm test -- --run
+npm run build
+npx playwright test
+```
+
+Expected: all PASS.
+
+- [ ] **Step 4: Commit**
+
+Run:
+
+```powershell
+Set-Location D:\work\senlinai\agent
+git add apps\web\e2e\project-workbench.spec.ts
+git commit -m "test: cover project channel workbench flow"
+```
+
+---
+
+## Self-Review
+
+Spec coverage:
+
+- Login page with server IP/domain, account, password, status, and errors: Task 1.
+- Dynamic project rail and project-scoped channel refresh: Tasks 2 and 3.
+- System channels with different page templates: Task 4.
+- Message flow similar to email: Task 4.
+- Work plan Todo card style with complete/incomplete state: Task 4.
+- AI sessions with session list and details: Task 4.
+- Notes and sources file/attachment management style: Task 4.
+- Cron plan/reminder management without autonomous Agent execution: Task 4 and Global Constraints.
+- Custom channel with title, icon, URL: Tasks 2 and 4.
+- Right inspector with Discussion, Properties, More: Task 4.
+- shadcn/ui-inspired visual system without React/shadcn dependency: Task 5.
+- Playwright workflow coverage: Task 6.
+
+Completeness scan:
+
+- The plan avoids unresolved markers, deferred-work wording, and vague validation instructions.
+- Each code-writing step includes concrete file content or concrete implementation shape with exact paths.
+
+Type consistency:
+
+- `ChannelType`, `WorkbenchChannel`, `ProjectWorkspace`, and `InspectorItem` are introduced in Task 2 and consumed consistently in Tasks 3 and 4.
+- `onInspect(item: InspectorItem): void` is the only inspector selection interface.
+- `onLogin({ apiBase, account })` is the only login completion interface.
diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md
new file mode 100644
index 0000000..7b178dc
--- /dev/null
+++ b/.superpowers/sdd/task-6-report.md
@@ -0,0 +1,47 @@
+# Task 6 Report: Playwright Smoke Flow
+
+## Status
+
+Completed and committed the scoped Playwright smoke-flow update.
+
+## What Changed
+
+Replaced the legacy workbench shell and inbox smoke tests with one end-to-end test, `login and project channel workbench flow`. The flow covers:
+
+- Login using the server address, account, and password fields.
+- Initial Project A1 selection and visibility of the Message Flow 36 channel.
+- Switching to the Work Plan 8 channel and confirming its task template and inspector action.
+- Switching projects to Project A2 and confirming its active state and channel refresh.
+- Opening the Ops Dashboard custom URL channel and confirming its external-channel state.
+
+## Tests
+
+All verification commands completed successfully from `apps/web`:
+
+| Command | Result |
+| --- | --- |
+| `npx playwright test` | 1 passed |
+| `npm test -- --run` | 6 files, 13 tests passed |
+| `npm run build` | Passed |
+| `npx playwright test` (final verification) | 1 passed |
+
+## Files Changed
+
+- `apps/web/e2e/project-workbench.spec.ts`: replaced the previous smoke cases with the required login, project-switching, channel-template, and custom URL-channel flow.
+- `.superpowers/sdd/task-6-report.md`: this implementation report.
+
+## Self-Review
+
+- Used the exact labels, button names, values, and assertions supplied in the Task 6 brief.
+- Confirmed the smoke flow checks both the default active project and the dynamically refreshed Project A2 channel list.
+- Confirmed channel coverage reaches both the Work Plan template and the custom external URL channel state.
+- Kept application code and dependencies unchanged; no React or shadcn/ui dependency was added.
+- Confirmed `git diff --check` exits successfully and the code diff is scoped to the requested Playwright spec.
+
+## Concerns
+
+None. Vite reports that no Svelte config file is present and uses its default configuration during tests and build; this is an existing informational message and did not affect verification.
+
+## Commit
+
+`test: cover project channel workbench flow`
diff --git a/.superpowers/sdd/task-6-review-package.md b/.superpowers/sdd/task-6-review-package.md
new file mode 100644
index 0000000..6826a24
--- /dev/null
+++ b/.superpowers/sdd/task-6-review-package.md
@@ -0,0 +1,52 @@
+# Review package Task 6
+
+## Commits
+6341a6b test: cover project channel workbench flow
+
+## Stat
+ apps/web/e2e/project-workbench.spec.ts | 31 ++++++++++++++++++++-----------
+ 1 file changed, 20 insertions(+), 11 deletions(-)
+
+## Diff
+diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
+index 8a2358a..b1fee8b 100644
+--- a/apps/web/e2e/project-workbench.spec.ts
++++ b/apps/web/e2e/project-workbench.spec.ts
+@@ -1,17 +1,26 @@
+ import { expect, test } from '@playwright/test';
+
+-test('project workbench shell renders', async ({ page }) => {
++test('login and project channel workbench flow', async ({ page }) => {
+ await page.goto('/');
+- await expect(page.getByText('项目工作台')).toBeVisible();
+- await expect(page.getByLabel('服务器登录')).toBeVisible();
+- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
+- await expect(page.getByLabel('项目总览')).toBeVisible();
+-});
+
+-test('inbox suggestions require explicit confirmation', async ({ page }) => {
+- await page.goto('/');
+- await page.getByLabel('选择 跟进报价').check();
+- await page.getByRole('button', { name: '创建选中项' }).click();
++ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
++ await page.getByLabel('Server IP or domain').fill('localhost:8080');
++ await page.getByLabel('Email or username').fill('david@example.com');
++ await page.getByLabel('Password').fill('secret');
++ await page.getByRole('button', { name: 'Log in' }).click();
++
++ await expect(page.getByLabel('Project list')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Work Plan 8' }).click();
++ await expect(page.getByText('Tasks')).toBeVisible();
++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
++
++ await page.getByRole('button', { name: 'Project A2' }).click();
++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
++ await expect(page.getByText('Ops Dashboard')).toBeVisible();
+
+- await expect(page.getByText('已选择 1 项')).toBeVisible();
++ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
++ await expect(page.getByText('Open external channel')).toBeVisible();
+ });
diff --git a/design-qa-comparison.png b/design-qa-comparison.png
new file mode 100644
index 0000000..897c9fd
Binary files /dev/null and b/design-qa-comparison.png differ
diff --git a/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png b/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png
new file mode 100644
index 0000000..ca87df3
Binary files /dev/null and b/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png differ