diff --git a/.superpowers/sdd/final-review-package-v2.md b/.superpowers/sdd/final-review-package-v2.md deleted file mode 100644 index 02e8b86..0000000 --- a/.superpowers/sdd/final-review-package-v2.md +++ /dev/null @@ -1,1825 +0,0 @@ -# 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <ProjectWorkbench {currentUser} /> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -+ let account = ''; -+ let password = ''; -+ let error = ''; -+ let connectionStatus = 'Not connected. Enter a server address to continue.'; - -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -+ function normalizeServer(value: string): { apiBase?: string; error?: string } { -+ const trimmed = value.trim(); -+ if (!trimmed) { -+ return { error: 'Enter a server address.' }; -+ } -+ -+ const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; -+ -+ try { -+ const parsed = new URL(candidate); -+ if (!parsed.hostname || !['http:', 'https:'].includes(parsed.protocol)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ return { apiBase: candidate }; -+ } catch { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } - } - -- function normalizeServer(value: string) { -- const trimmed = value.trim(); -- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { -- return trimmed; -+ function submitLogin() { -+ error = ''; -+ const normalized = normalizeServer(server); -+ if (normalized.error) { -+ error = normalized.error; -+ connectionStatus = 'Check the server address before signing in.'; -+ return; - } -- return `http://${trimmed}`; -+ -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ server = normalized.apiBase ?? server; -+ connectionStatus = 'Connection settings are ready.'; -+ onLogin({ apiBase: server, account: account.trim() }); - } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <p class="connection-status" role="status">{connectionStatus}</p> -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel tasks={workspace.tasks} {onInspect} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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<keyof typeof expectedContent>) { -+ 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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button">Copy link</button> -+ <button type="button">Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+ -+ const channelIcons: Record<WorkbenchChannel['icon'], string> = { -+ home: '\u2302', -+ mail: '\u2709', -+ list: '\u2630', -+ sparkles: '\u2726', -+ file: '\u25A4', -+ clock: '\u25F7', -+ link: '\u21AA', -+ }; -+ -+ $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings" disabled>⚙</button> -+ </header> -+ -+ <ul class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <li>#{tag}</li> -+ {/each} -+ </ul> -+ -+ <section class="channel-group"> -+ {#each sortedChannels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span> -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ <ul> -+ {#each recentSessions as session} -+ <li> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </li> -+ {/each} -+ </ul> -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard" disabled>SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project" disabled>+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem } from './types'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; -+ -+ function selectProject(projectID: number) { -+ selectedItem = null; -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedItem = null; -+ selectedChannelID = channelID; -+ } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> -+ </section> -+ <ObjectInspector item={selectedItem} /> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back" disabled>←</button> -+ <button aria-label="Forward" disabled>→</button> -+ <button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ let copyStatus = ''; -+ -+ async function copyUrl() { -+ try { -+ await navigator.clipboard?.writeText(channel.url ?? ''); -+ copyStatus = 'URL copied.'; -+ } catch { -+ copyStatus = 'Copy unavailable in this browser.'; -+ } -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button> -+ {#if copyStatus} -+ <p class="copy-feedback" aria-live="polite">{copyStatus}</p> -+ {/if} -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ let taskStates: WorkTask[] = []; -+ let sourceTasks: WorkTask[] | undefined; -+ -+ $: if (tasks !== sourceTasks) { -+ sourceTasks = tasks; -+ taskStates = tasks.map((task) => ({ ...task })); -+ } -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+ -+ function toggleTask(taskID: string) { -+ taskStates = taskStates.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task); -+ } -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each taskStates as task} -+ <article class:completed={task.completed} class="task-card"> -+ <input -+ class="task-check" -+ type="checkbox" -+ aria-label={`Mark ${task.title} complete`} -+ checked={task.completed} -+ on:change={() => toggleTask(task.id)} -+ /> -+ <div> -+ <h2>{task.title}</h2> -+ <p>{task.summary}</p> -+ <small>{task.owner} / {task.due} / #{task.tag}</small> -+ <span class="task-status">{task.completed ? 'Completed' : 'Open'}</span> -+ </div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 deleted file mode 100644 index 797cb55..0000000 --- a/.superpowers/sdd/final-review-package-v3.md +++ /dev/null @@ -1,1952 +0,0 @@ -# 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 <title>` 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <ProjectWorkbench {currentUser} /> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -+ let account = ''; -+ let password = ''; -+ let error = ''; -+ let connectionStatus = 'Not connected. Enter a server address to continue.'; - -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -+ function normalizeServer(value: string): { apiBase?: string; error?: string } { -+ if (!value.trim()) { -+ return { error: 'Enter a server address.' }; -+ } -+ -+ if (hasWhitespace(value) || hasUnsupportedProtocol(value)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ -+ const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`; -+ -+ try { -+ const parsed = new URL(candidate); -+ if (!parsed.hostname || parsed.username || parsed.password || !['http:', 'https:'].includes(parsed.protocol)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ return { apiBase: candidate }; -+ } catch { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ } -+ -+ function hasWhitespace(value: string): boolean { -+ if (/\s/u.test(value)) return true; -+ -+ try { -+ return /\s/u.test(decodeURIComponent(value)); -+ } catch { -+ return true; -+ } -+ } -+ -+ function hasUnsupportedProtocol(value: string): boolean { -+ const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i); -+ if (explicitProtocol) { -+ return !['http', 'https'].includes(explicitProtocol[1].toLowerCase()); -+ } -+ -+ return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value); - } - -- function normalizeServer(value: string) { -- const trimmed = value.trim(); -- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { -- return trimmed; -+ function submitLogin() { -+ error = ''; -+ const normalized = normalizeServer(server); -+ if (normalized.error) { -+ error = normalized.error; -+ connectionStatus = 'Check the server address before signing in.'; -+ return; - } -- return `http://${trimmed}`; -+ -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ server = normalized.apiBase ?? server; -+ connectionStatus = 'Connection settings are ready.'; -+ onLogin({ apiBase: server, account: account.trim() }); - } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <p class="connection-status" role="status">{connectionStatus}</p> -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let tasks: WorkTask[] = workspace.tasks; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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<keyof typeof expectedContent>) { -+ 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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+ let copyFeedback = ''; -+ let currentItem: InspectorItem | null = null; -+ -+ $: if (item !== currentItem) { -+ currentItem = item; -+ copyFeedback = ''; -+ } -+ -+ function copyLink() { -+ const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString(); -+ void navigator.clipboard?.writeText(link); -+ copyFeedback = 'Link copied to clipboard.'; -+ } -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button" on:click={copyLink}>Copy link</button> -+ {#if copyFeedback} -+ <p class="copy-feedback" role="status">{copyFeedback}</p> -+ {/if} -+ <button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+ -+ const channelIcons: Record<WorkbenchChannel['icon'], string> = { -+ home: '\u2302', -+ mail: '\u2709', -+ list: '\u2630', -+ sparkles: '\u2726', -+ file: '\u25A4', -+ clock: '\u25F7', -+ link: '\u21AA', -+ }; -+ -+ $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings" disabled>⚙</button> -+ </header> -+ -+ <ul class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <li>#{tag}</li> -+ {/each} -+ </ul> -+ -+ <section class="channel-group"> -+ {#each sortedChannels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span> -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ <ul> -+ {#each recentSessions as session} -+ <li> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </li> -+ {/each} -+ </ul> -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard" disabled>SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project" disabled>+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem, WorkTask } from './types'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; -+ let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries( -+ workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]), -+ ); -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks; -+ $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; -+ -+ function selectProject(projectID: number) { -+ selectedItem = null; -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedItem = null; -+ selectedChannelID = channelID; -+ } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } -+ -+ function toggleTask(taskID: string) { -+ tasksByProjectID = { -+ ...tasksByProjectID, -+ [selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task), -+ }; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} /> -+ </section> -+ <ObjectInspector item={selectedItem} /> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back" disabled>←</button> -+ <button aria-label="Forward" disabled>→</button> -+ <button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ let copyStatus = ''; -+ -+ async function copyUrl() { -+ try { -+ await navigator.clipboard?.writeText(channel.url ?? ''); -+ copyStatus = 'URL copied.'; -+ } catch { -+ copyStatus = 'Copy unavailable in this browser.'; -+ } -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button> -+ {#if copyStatus} -+ <p class="copy-feedback" aria-live="polite">{copyStatus}</p> -+ {/if} -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+ -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <input -+ class="task-check" -+ type="checkbox" -+ aria-label={`Mark ${task.title} complete`} -+ checked={task.completed} -+ on:change={() => onToggleTask(task.id)} -+ /> -+ <div> -+ <h2>{task.title}</h2> -+ <p>{task.summary}</p> -+ <small>{task.owner} / {task.due} / #{task.tag}</small> -+ <span class="task-status">{task.completed ? 'Completed' : 'Open'}</span> -+ </div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 deleted file mode 100644 index 1abd6ca..0000000 --- a/.superpowers/sdd/final-review-package-v4.md +++ /dev/null @@ -1,2162 +0,0 @@ -# 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 <title>` 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <ProjectWorkbench {currentUser} /> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -+ let account = ''; -+ let password = ''; -+ let error = ''; -+ let connectionStatus = 'Not connected. Enter a server address to continue.'; - -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -+ function normalizeServer(value: string): { apiBase?: string; error?: string } { -+ if (!value.trim()) { -+ return { error: 'Enter a server address.' }; -+ } -+ -+ if (hasWhitespace(value) || hasUnsupportedProtocol(value)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ -+ const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`; -+ if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ -+ try { -+ const parsed = new URL(candidate); -+ if ( -+ !parsed.hostname || -+ parsed.username || -+ parsed.password || -+ !['http:', 'https:'].includes(parsed.protocol) || -+ !isValidHostname(parsed.hostname) -+ ) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ return { apiBase: candidate }; -+ } catch { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } - } - -- function normalizeServer(value: string) { -- const trimmed = value.trim(); -- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { -- return trimmed; -+ function hasWhitespace(value: string): boolean { -+ if (/\s/u.test(value)) return true; -+ -+ try { -+ return /\s/u.test(decodeURIComponent(value)); -+ } catch { -+ return true; -+ } -+ } -+ -+ function hasUnsupportedProtocol(value: string): boolean { -+ const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i); -+ if (explicitProtocol) { -+ return !['http', 'https'].includes(explicitProtocol[1].toLowerCase()); - } -- return `http://${trimmed}`; -+ -+ return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value); -+ } -+ -+ function hasCredentials(value: string): boolean { -+ const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0]; -+ return authority.includes('@'); -+ } -+ -+ function isValidHostname(hostname: string): boolean { -+ if (hostname === 'localhost') return true; -+ if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true; -+ if (/^\d+(?:\.\d+){3}$/.test(hostname)) { -+ return hostname.split('.').every((segment) => Number(segment) <= 255); -+ } -+ -+ return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label)); -+ } -+ -+ function submitLogin() { -+ error = ''; -+ const normalized = normalizeServer(server); -+ if (normalized.error) { -+ error = normalized.error; -+ connectionStatus = 'Check the server address before signing in.'; -+ return; -+ } -+ -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ server = normalized.apiBase ?? server; -+ connectionStatus = 'Connection settings are ready.'; -+ onLogin({ apiBase: server, account: account.trim() }); - } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <p class="connection-status" role="status">{connectionStatus}</p> -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let tasks: WorkTask[] = workspace.tasks; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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<keyof typeof expectedContent>) { -+ 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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+ let copyFeedback = ''; -+ let copyFailed = false; -+ let currentItem: InspectorItem | null = null; -+ -+ $: if (item !== currentItem) { -+ currentItem = item; -+ copyFeedback = ''; -+ copyFailed = false; -+ } -+ -+ async function copyLink() { -+ const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString(); -+ if (!navigator.clipboard?.writeText) { -+ copyFailed = true; -+ copyFeedback = 'Clipboard access is unavailable.'; -+ return; -+ } -+ -+ try { -+ await navigator.clipboard.writeText(link); -+ copyFailed = false; -+ copyFeedback = 'Link copied to clipboard.'; -+ } catch { -+ copyFailed = true; -+ copyFeedback = 'Unable to copy link.'; -+ } -+ } -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button" on:click={copyLink}>Copy link</button> -+ {#if copyFeedback} -+ <p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p> -+ {/if} -+ <button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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<void>((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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+ -+ const channelIcons: Record<WorkbenchChannel['icon'], string> = { -+ home: '\u2302', -+ mail: '\u2709', -+ list: '\u2630', -+ sparkles: '\u2726', -+ file: '\u25A4', -+ clock: '\u25F7', -+ link: '\u21AA', -+ }; -+ -+ $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings" disabled>⚙</button> -+ </header> -+ -+ <ul class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <li>#{tag}</li> -+ {/each} -+ </ul> -+ -+ <section class="channel-group"> -+ {#each sortedChannels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span> -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ <ul> -+ {#each recentSessions as session} -+ <li> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </li> -+ {/each} -+ </ul> -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard" disabled>SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project" disabled>+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem, WorkTask } from './types'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; -+ let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries( -+ workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]), -+ ); -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks; -+ $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; -+ -+ function selectProject(projectID: number) { -+ selectedItem = null; -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedItem = null; -+ selectedChannelID = channelID; -+ } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } -+ -+ function toggleTask(taskID: string) { -+ tasksByProjectID = { -+ ...tasksByProjectID, -+ [selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task), -+ }; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} /> -+ </section> -+ <ObjectInspector item={selectedItem} /> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back" disabled>←</button> -+ <button aria-label="Forward" disabled>→</button> -+ <button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ let copyStatus = ''; -+ let copyFailed = false; -+ -+ async function copyUrl() { -+ if (!navigator.clipboard?.writeText) { -+ copyFailed = true; -+ copyStatus = 'Copy unavailable in this browser.'; -+ return; -+ } -+ -+ try { -+ await navigator.clipboard.writeText(channel.url ?? ''); -+ copyFailed = false; -+ copyStatus = 'URL copied.'; -+ } catch { -+ copyFailed = true; -+ copyStatus = 'Unable to copy URL.'; -+ } -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button> -+ {#if copyStatus} -+ <p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p> -+ {/if} -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+ -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <input -+ class="task-check" -+ type="checkbox" -+ aria-label={`Mark ${task.title} complete`} -+ checked={task.completed} -+ on:change={() => onToggleTask(task.id)} -+ /> -+ <div> -+ <h2>{task.title}</h2> -+ <p>{task.summary}</p> -+ <small>{task.owner} / {task.due} / #{task.tag}</small> -+ <span class="task-status">{task.completed ? 'Completed' : 'Open'}</span> -+ </div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 deleted file mode 100644 index 527057c..0000000 --- a/.superpowers/sdd/final-review-package-v5.md +++ /dev/null @@ -1,2044 +0,0 @@ -# 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <ProjectWorkbench {currentUser} /> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -+ let account = ''; -+ let password = ''; -+ let error = ''; -+ let connectionStatus = 'Not connected. Enter a server address to continue.'; - -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -+ function normalizeServer(value: string): { apiBase?: string; error?: string } { -+ if (!value.trim()) { -+ return { error: 'Enter a server address.' }; -+ } -+ -+ if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ -+ const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`; -+ if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ -+ try { -+ const parsed = new URL(candidate); -+ if ( -+ !parsed.hostname || -+ parsed.username || -+ parsed.password || -+ !['http:', 'https:'].includes(parsed.protocol) || -+ !isValidHostname(parsed.hostname) -+ ) { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } -+ return { apiBase: candidate }; -+ } catch { -+ return { error: 'Enter a valid HTTP or HTTPS server address.' }; -+ } - } - -- function normalizeServer(value: string) { -- const trimmed = value.trim(); -- if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { -- return trimmed; -+ function hasWhitespace(value: string): boolean { -+ if (/\s/u.test(value)) return true; -+ -+ try { -+ return /\s/u.test(decodeURIComponent(value)); -+ } catch { -+ return true; - } -- return `http://${trimmed}`; -+ } -+ -+ function hasUnsupportedProtocol(value: string): boolean { -+ const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i); -+ if (explicitProtocol) { -+ return !['http', 'https'].includes(explicitProtocol[1].toLowerCase()); -+ } -+ -+ return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value); -+ } -+ -+ function hasMalformedHttpProtocol(value: string): boolean { -+ return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value); -+ } -+ -+ function hasCredentials(value: string): boolean { -+ const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0]; -+ return authority.includes('@'); -+ } -+ -+ function isValidHostname(hostname: string): boolean { -+ if (hostname === 'localhost') return true; -+ if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true; -+ if (/^\d+(?:\.\d+){3}$/.test(hostname)) { -+ return hostname.split('.').every((segment) => Number(segment) <= 255); -+ } -+ -+ return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label)); -+ } -+ -+ function submitLogin() { -+ error = ''; -+ const normalized = normalizeServer(server); -+ if (normalized.error) { -+ error = normalized.error; -+ connectionStatus = 'Check the server address before signing in.'; -+ return; -+ } -+ -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ server = normalized.apiBase ?? server; -+ connectionStatus = 'Connection settings are ready.'; -+ onLogin({ apiBase: server, account: account.trim() }); - } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <p class="connection-status" role="status">{connectionStatus}</p> -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let tasks: WorkTask[] = workspace.tasks; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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<keyof typeof expectedContent>) { -+ 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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+ let copyFeedback = ''; -+ let copyFailed = false; -+ let currentItem: InspectorItem | null = null; -+ -+ $: if (item !== currentItem) { -+ currentItem = item; -+ copyFeedback = ''; -+ copyFailed = false; -+ } -+ -+ async function copyLink() { -+ const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString(); -+ if (!navigator.clipboard?.writeText) { -+ copyFailed = true; -+ copyFeedback = 'Clipboard access is unavailable.'; -+ return; -+ } -+ -+ try { -+ await navigator.clipboard.writeText(link); -+ copyFailed = false; -+ copyFeedback = 'Link copied to clipboard.'; -+ } catch { -+ copyFailed = true; -+ copyFeedback = 'Unable to copy link.'; -+ } -+ } -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button" on:click={copyLink}>Copy link</button> -+ {#if copyFeedback} -+ <p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p> -+ {/if} -+ <button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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<void>((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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+ -+ const channelIcons: Record<WorkbenchChannel['icon'], string> = { -+ home: '\u2302', -+ mail: '\u2709', -+ list: '\u2630', -+ sparkles: '\u2726', -+ file: '\u25A4', -+ clock: '\u25F7', -+ link: '\u21AA', -+ }; -+ -+ $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings" disabled>⚙</button> -+ </header> -+ -+ <ul class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <li>#{tag}</li> -+ {/each} -+ </ul> -+ -+ <section class="channel-group"> -+ {#each sortedChannels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span> -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ <ul> -+ {#each recentSessions as session} -+ <li> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </li> -+ {/each} -+ </ul> -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard" disabled>SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project" disabled>+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem, WorkTask } from './types'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; -+ let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries( -+ workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]), -+ ); -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks; -+ $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; -+ -+ function selectProject(projectID: number) { -+ selectedItem = null; -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedItem = null; -+ selectedChannelID = channelID; -+ } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } -+ -+ function toggleTask(taskID: string) { -+ tasksByProjectID = { -+ ...tasksByProjectID, -+ [selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task), -+ }; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} /> -+ </section> -+ <ObjectInspector item={selectedItem} /> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back" disabled>←</button> -+ <button aria-label="Forward" disabled>→</button> -+ <button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ let copyStatus = ''; -+ let copyFailed = false; -+ -+ async function copyUrl() { -+ if (!navigator.clipboard?.writeText) { -+ copyFailed = true; -+ copyStatus = 'Copy unavailable in this browser.'; -+ return; -+ } -+ -+ try { -+ await navigator.clipboard.writeText(channel.url ?? ''); -+ copyFailed = false; -+ copyStatus = 'URL copied.'; -+ } catch { -+ copyFailed = true; -+ copyStatus = 'Unable to copy URL.'; -+ } -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button> -+ {#if copyStatus} -+ <p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p> -+ {/if} -+</section> -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<void>((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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ export let onToggleTask: (taskID: string) => void = () => {}; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+ -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <input -+ class="task-check" -+ type="checkbox" -+ aria-label={`Mark ${task.title} complete`} -+ checked={task.completed} -+ on:change={() => onToggleTask(task.id)} -+ /> -+ <div> -+ <h2>{task.title}</h2> -+ <p>{task.summary}</p> -+ <small>{task.owner} / {task.due} / #{task.tag}</small> -+ <span class="task-status">{task.completed ? 'Completed' : 'Open'}</span> -+ </div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 deleted file mode 100644 index ac582f4..0000000 --- a/.superpowers/sdd/final-review-package.md +++ /dev/null @@ -1,1544 +0,0 @@ -# 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 <title>` 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <ProjectWorkbench {currentUser} /> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -- -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -- } -+ let account = ''; -+ let password = ''; -+ let error = ''; - - function normalizeServer(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - return `http://${trimmed}`; - } -+ -+ function submitLogin() { -+ error = ''; -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ const normalized = normalizeServer(server); -+ server = normalized; -+ onLogin({ apiBase: normalized, account: account.trim() }); -+ } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel tasks={workspace.tasks} {onInspect} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button">Copy link</button> -+ <button type="button">Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings">⚙</button> -+ </header> -+ -+ <section class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <button type="button">#{tag}</button> -+ {/each} -+ </section> -+ -+ <section class="channel-group"> -+ {#each channels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ {#each recentSessions as session} -+ <button type="button"> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </button> -+ {/each} -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard">SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project">+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem } from './types'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; -+ -+ function selectProject(projectID: number) { -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedChannelID = channelID; -+ } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> -+ </section> -+ <ObjectInspector item={selectedItem} /> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back">←</button> -+ <button aria-label="Forward">→</button> -+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ -+ function copyUrl() { -+ void navigator.clipboard?.writeText(channel.url ?? ''); -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button type="button" on:click={copyUrl}>Copy URL</button> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span> -+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 deleted file mode 100644 index 9d0e02c..0000000 --- a/.superpowers/sdd/progress.md +++ /dev/null @@ -1,8 +0,0 @@ -# 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 deleted file mode 100644 index 1597d7c..0000000 --- a/.superpowers/sdd/task-1-brief.md +++ /dev/null @@ -1,200 +0,0 @@ -## 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 -<script lang="ts"> - export let apiBase: string; - export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - - let server = apiBase; - let account = ''; - let password = ''; - let error = ''; - - function normalizeServer(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - return `http://${trimmed}`; - } - - function submitLogin() { - error = ''; - if (!account.trim() || !password.trim()) { - error = 'Enter an account and password.'; - return; - } - - const normalized = normalizeServer(server); - server = normalized; - onLogin({ apiBase: normalized, account: account.trim() }); - } -</script> - -<main class="login-page"> - <section class="login-panel" aria-labelledby="login-title"> - <div class="login-brand" aria-hidden="true">SA</div> - <div class="login-heading"> - <p>Private workbench</p> - <h1 id="login-title">SenlinAI Workbench</h1> - </div> - - <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> - <label for="server-address">Server IP or domain</label> - <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> - - <label for="login-account">Email or username</label> - <input id="login-account" name="account" bind:value={account} autocomplete="username" /> - - <label for="login-password">Password</label> - <input - id="login-password" - name="password" - type="password" - bind:value={password} - autocomplete="current-password" - /> - - {#if error} - <p class="form-error" aria-live="polite">{error}</p> - {/if} - - <button type="submit">Log in</button> - <p class="login-note">The server address is remembered on this device.</p> - </form> - </section> -</main> -``` - -- [ ] **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 -<script lang="ts"> - import ServerLogin from '../features/auth/ServerLogin.svelte'; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; - let currentUser: { account: string } | null = null; - - function handleLogin(detail: { apiBase: string; account: string }) { - apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); - currentUser = { account: detail.account }; - } -</script> - -{#if currentUser} - <main class="workbench-placeholder" aria-label="Project workbench"> - <h1>SenlinAI Workbench</h1> - <p>Signed in as {currentUser.account}</p> - </main> -{:else} - <ServerLogin {apiBase} onLogin={handleLogin} /> -{/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 deleted file mode 100644 index 3887012..0000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index 8b8d938..0000000 --- a/.superpowers/sdd/task-1-review-package.md +++ /dev/null @@ -1,242 +0,0 @@ -# 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 @@ - <script lang="ts"> -- import { onMount } from 'svelte'; -- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; -- import SuggestionList from '../features/inbox/SuggestionList.svelte'; -- import type { Suggestion } from '../features/inbox/types'; -- import { createApi, type ProjectDashboardSummary } from '../lib/api'; -- -- const emptyDashboard: ProjectDashboardSummary = { -- project_id: 1, -- pending_inbox_count: 0, -- open_task_count: 0, -- recent_note_count: 0, -- recent_session_count: 0, -- }; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; -- let dashboard = emptyDashboard; -- let connectionStatus = '未连接'; -- let selectedSuggestionCount = 0; -- -- const sampleSuggestions: Suggestion[] = [ -- { -- kind: 'task', -- title: '跟进报价', -- body: '从项目 inbox 确认后创建任务,并保留来源记录。', -- }, -- { -- kind: 'note', -- title: '客户背景', -- body: '把对话中的背景信息沉淀成项目笔记。', -- }, -- ]; -+ let currentUser: { account: string } | null = null; - -- onMount(() => { -- void loadDashboard(); -- }); -- -- function handleServerChange(event: CustomEvent<{ apiBase: string }>) { -- apiBase = event.detail.apiBase; -+ function handleLogin(detail: { apiBase: string; account: string }) { -+ apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); -- void loadDashboard(); -- } -- -- async function loadDashboard() { -- connectionStatus = '连接中'; -- try { -- dashboard = await createApi(apiBase).getProjectDashboard(1); -- connectionStatus = '已连接'; -- } catch { -- dashboard = emptyDashboard; -- connectionStatus = '无法连接服务器'; -- } -- } -- -- function handleConfirm(selected: Suggestion[]) { -- selectedSuggestionCount = selected.length; -+ currentUser = { account: detail.account }; - } - </script> - --<main class="shell"> -- <aside class="sidebar"> -- <h1>项目工作台</h1> -- <ServerLogin {apiBase} on:serverChange={handleServerChange} /> -- <p class="connection-status" aria-live="polite">{connectionStatus}</p> -- </aside> -- -- <section class="workspace"> -- <ProjectDashboard summary={dashboard} /> -- <section class="inbox-review" aria-label="项目 inbox"> -- <h2>AI 整理建议</h2> -- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p> -- </section> -- </section> --</main> -+{#if currentUser} -+ <main class="workbench-placeholder" aria-label="Project workbench"> -+ <h1>SenlinAI Workbench</h1> -+ <p>Signed in as {currentUser.account}</p> -+ </main> -+{:else} -+ <ServerLogin {apiBase} onLogin={handleLogin} /> -+{/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 @@ - <script lang="ts"> -- import { createEventDispatcher } from 'svelte'; -- - export let apiBase: string; -+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {}; - -- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>(); - let server = apiBase; -- -- function saveServer() { -- const normalized = normalizeServer(server); -- server = normalized; -- dispatch('serverChange', { apiBase: normalized }); -- } -+ let account = ''; -+ let password = ''; -+ let error = ''; - - function normalizeServer(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - return `http://${trimmed}`; - } -+ -+ function submitLogin() { -+ error = ''; -+ if (!account.trim() || !password.trim()) { -+ error = 'Enter an account and password.'; -+ return; -+ } -+ -+ const normalized = normalizeServer(server); -+ server = normalized; -+ onLogin({ apiBase: normalized, account: account.trim() }); -+ } - </script> - --<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> -- <label for="server-address">服务器 IP 或域名</label> -- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -- <button type="submit">保存服务器</button> --</form> -+<main class="login-page"> -+ <section class="login-panel" aria-labelledby="login-title"> -+ <div class="login-brand" aria-hidden="true">SA</div> -+ <div class="login-heading"> -+ <p>Private workbench</p> -+ <h1 id="login-title">SenlinAI Workbench</h1> -+ </div> -+ -+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}> -+ <label for="server-address">Server IP or domain</label> -+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> -+ -+ <label for="login-account">Email or username</label> -+ <input id="login-account" name="account" bind:value={account} autocomplete="username" /> -+ -+ <label for="login-password">Password</label> -+ <input -+ id="login-password" -+ name="password" -+ type="password" -+ bind:value={password} -+ autocomplete="current-password" -+ /> -+ -+ {#if error} -+ <p class="form-error" aria-live="polite">{error}</p> -+ {/if} -+ -+ <button type="submit">Log in</button> -+ <p class="login-note">The server address is remembered on this device.</p> -+ </form> -+ </section> -+</main> -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 deleted file mode 100644 index 6e03842..0000000 --- a/.superpowers/sdd/task-2-brief.md +++ /dev/null @@ -1,250 +0,0 @@ -## 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 deleted file mode 100644 index 0e499f8..0000000 --- a/.superpowers/sdd/task-2-review-package.md +++ /dev/null @@ -1,264 +0,0 @@ -# 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 deleted file mode 100644 index 93b273f..0000000 --- a/.superpowers/sdd/task-3-brief.md +++ /dev/null @@ -1,263 +0,0 @@ -## 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 -<!-- apps/web/src/features/workbench/ProjectRail.svelte --> -<script lang="ts"> - import type { WorkbenchProject } from './types'; - export let projects: WorkbenchProject[]; - export let selectedProjectID: number; - export let onSelectProject: (projectID: number) => void; -</script> - -<nav class="project-rail" aria-label="Project list"> - <button class="rail-logo" aria-label="Dashboard">SA</button> - {#each projects as project} - <button - class:active={project.id === selectedProjectID} - aria-pressed={project.id === selectedProjectID} - aria-label={project.name} - title={project.name} - on:click={() => onSelectProject(project.id)} - > - <span>{project.initials}</span> - {#if project.unreadCount} - <small>{project.unreadCount}</small> - {/if} - </button> - {/each} - <button class="add-project" aria-label="Create project">+</button> -</nav> -``` - -```svelte -<!-- apps/web/src/features/workbench/ProjectChannelSidebar.svelte --> -<script lang="ts"> - import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; - export let project: WorkbenchProject; - export let channels: WorkbenchChannel[]; - export let selectedChannelID: string; - export let tags: string[]; - export let recentSessions: AISessionItem[]; - export let onSelectChannel: (channelID: string) => void; -</script> - -<aside class="channel-sidebar" aria-label="Project channels"> - <header> - <div> - <p>Current project</p> - <h2>{project.name}</h2> - </div> - <button aria-label="Project settings">鈿?/button> - </header> - - <section class="tag-row" aria-label="Project tags"> - {#each tags as tag} - <button type="button">#{tag}</button> - {/each} - </section> - - <section class="channel-group"> - {#each channels as channel} - <button - class:active={channel.id === selectedChannelID} - aria-pressed={channel.id === selectedChannelID} - aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} - on:click={() => onSelectChannel(channel.id)} - > - <span>{channel.title}</span> - {#if channel.count !== undefined} - <small>{channel.count}</small> - {/if} - </button> - {/each} - </section> - - <section class="recent-sessions" aria-label="Recent sessions"> - <h3>Recent sessions</h3> - {#each recentSessions as session} - <button type="button"> - <span>{session.title}</span> - <small>{session.updatedAt}</small> - </button> - {/each} - </section> -</aside> -``` - -```svelte -<!-- apps/web/src/features/workbench/WorkspaceTopbar.svelte --> -<script lang="ts"> - export let account: string; -</script> - -<header class="workspace-topbar"> - <div class="brand-mark">SenlinAI</div> - <label class="search-box"> - <span>Search</span> - <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" /> - </label> - <div class="topbar-actions"> - <button aria-label="Back">鈫?/button> - <button aria-label="Forward">鈫?/button> - <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button> - </div> -</header> -``` - -- [ ] **Step 4: Create `ProjectWorkbench.svelte` and wire into `App.svelte`** - -Create `ProjectWorkbench.svelte`: - -```svelte -<script lang="ts"> - import { getProjectWorkspace, workbenchProjects } from './mockData'; - import ProjectRail from './ProjectRail.svelte'; - import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; - import WorkspaceTopbar from './WorkspaceTopbar.svelte'; - - export let currentUser: { account: string }; - - let selectedProjectID = workbenchProjects[0].id; - $: workspace = getProjectWorkspace(selectedProjectID); - $: selectedChannelID = workspace.channels[0].id; - - function selectProject(projectID: number) { - selectedProjectID = projectID; - } - - function selectChannel(channelID: string) { - selectedChannelID = channelID; - } -</script> - -<main class="workbench-shell"> - <WorkspaceTopbar account={currentUser.account} /> - <div class="workbench-body"> - <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> - <ProjectChannelSidebar - project={workspace.project} - channels={workspace.channels} - {selectedChannelID} - tags={workspace.tags} - recentSessions={workspace.recentSessions} - onSelectChannel={selectChannel} - /> - <section class="channel-stage" aria-label="Channel content"> - <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1> - </section> - <aside class="object-inspector" aria-label="Object inspector"> - <p>Select an item to inspect discussion, properties, and actions.</p> - </aside> - </div> -</main> -``` - -Modify `App.svelte` to import and render `ProjectWorkbench` when logged in: - -```svelte -<script lang="ts"> - import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; - let currentUser: { account: string } | null = null; - - function handleLogin(detail: { apiBase: string; account: string }) { - apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); - currentUser = { account: detail.account }; - } -</script> - -{#if currentUser} - <ProjectWorkbench {currentUser} /> -{:else} - <ServerLogin {apiBase} onLogin={handleLogin} /> -{/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 deleted file mode 100644 index 6b0768c..0000000 --- a/.superpowers/sdd/task-3-report.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 deleted file mode 100644 index 95c8c76..0000000 --- a/.superpowers/sdd/task-3-review-package.md +++ /dev/null @@ -1,234 +0,0 @@ -# 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 @@ - <script lang="ts"> -+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte'; - import ServerLogin from '../features/auth/ServerLogin.svelte'; - - let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; - let currentUser: { account: string } | null = null; - - function handleLogin(detail: { apiBase: string; account: string }) { - apiBase = detail.apiBase; - localStorage.setItem('apiBase', apiBase); - currentUser = { account: detail.account }; - } - </script> - - {#if currentUser} -- <main class="workbench-placeholder" aria-label="Project workbench"> -- <h1>SenlinAI Workbench</h1> -- <p>Signed in as {currentUser.account}</p> -- </main> -+ <ProjectWorkbench {currentUser} /> - {:else} - <ServerLogin {apiBase} onLogin={handleLogin} /> - {/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 @@ -+<script lang="ts"> -+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types'; -+ -+ export let project: WorkbenchProject; -+ export let channels: WorkbenchChannel[]; -+ export let selectedChannelID: string; -+ export let tags: string[]; -+ export let recentSessions: AISessionItem[]; -+ export let onSelectChannel: (channelID: string) => void; -+</script> -+ -+<aside class="channel-sidebar" aria-label="Project channels"> -+ <header> -+ <div> -+ <p>Current project</p> -+ <h2>{project.name}</h2> -+ </div> -+ <button aria-label="Project settings">⚙</button> -+ </header> -+ -+ <section class="tag-row" aria-label="Project tags"> -+ {#each tags as tag} -+ <button type="button">#{tag}</button> -+ {/each} -+ </section> -+ -+ <section class="channel-group"> -+ {#each channels as channel} -+ <button -+ class:active={channel.id === selectedChannelID} -+ aria-pressed={channel.id === selectedChannelID} -+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`} -+ on:click={() => onSelectChannel(channel.id)} -+ > -+ <span>{channel.title}</span> -+ {#if channel.count !== undefined} -+ <small>{channel.count}</small> -+ {/if} -+ </button> -+ {/each} -+ </section> -+ -+ <section class="recent-sessions" aria-label="Recent sessions"> -+ <h3>Recent sessions</h3> -+ {#each recentSessions as session} -+ <button type="button"> -+ <span>{session.title}</span> -+ <small>{session.updatedAt}</small> -+ </button> -+ {/each} -+ </section> -+</aside> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchProject } from './types'; -+ -+ export let projects: WorkbenchProject[]; -+ export let selectedProjectID: number; -+ export let onSelectProject: (projectID: number) => void; -+</script> -+ -+<nav class="project-rail" aria-label="Project list"> -+ <button class="rail-logo" aria-label="Dashboard">SA</button> -+ {#each projects as project} -+ <button -+ class:active={project.id === selectedProjectID} -+ aria-pressed={project.id === selectedProjectID} -+ aria-label={project.name} -+ title={project.name} -+ on:click={() => onSelectProject(project.id)} -+ > -+ <span>{project.initials}</span> -+ {#if project.unreadCount} -+ <small>{project.unreadCount}</small> -+ {/if} -+ </button> -+ {/each} -+ <button class="add-project" aria-label="Create project">+</button> -+</nav> -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 @@ -+<script lang="ts"> -+ import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ProjectRail from './ProjectRail.svelte'; -+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; -+ import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ -+ export let currentUser: { account: string }; -+ -+ let selectedProjectID = workbenchProjects[0].id; -+ $: workspace = getProjectWorkspace(selectedProjectID); -+ $: selectedChannelID = workspace.channels[0].id; -+ -+ function selectProject(projectID: number) { -+ selectedProjectID = projectID; -+ } -+ -+ function selectChannel(channelID: string) { -+ selectedChannelID = channelID; -+ } -+</script> -+ -+<main class="workbench-shell"> -+ <WorkspaceTopbar account={currentUser.account} /> -+ <div class="workbench-body"> -+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> -+ <ProjectChannelSidebar -+ project={workspace.project} -+ channels={workspace.channels} -+ {selectedChannelID} -+ tags={workspace.tags} -+ recentSessions={workspace.recentSessions} -+ onSelectChannel={selectChannel} -+ /> -+ <section class="channel-stage" aria-label="Channel content"> -+ <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1> -+ </section> -+ <aside class="object-inspector" aria-label="Object inspector"> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ </aside> -+ </div> -+</main> -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 @@ -+<script lang="ts"> -+ export let account: string; -+</script> -+ -+<header class="workspace-topbar"> -+ <div class="brand-mark">SenlinAI</div> -+ <label class="search-box"> -+ <span>Search</span> -+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" /> -+ </label> -+ <div class="topbar-actions"> -+ <button aria-label="Back">←</button> -+ <button aria-label="Forward">→</button> -+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button> -+ </div> -+</header> diff --git a/.superpowers/sdd/task-4-brief.md b/.superpowers/sdd/task-4-brief.md deleted file mode 100644 index 6700a40..0000000 --- a/.superpowers/sdd/task-4-brief.md +++ /dev/null @@ -1,232 +0,0 @@ -## 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 -<script lang="ts"> - import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; - import OverviewChannel from './channels/OverviewChannel.svelte'; - import InboxChannel from './channels/InboxChannel.svelte'; - import TasksChannel from './channels/TasksChannel.svelte'; - import AISessionsChannel from './channels/AISessionsChannel.svelte'; - import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; - import CronChannel from './channels/CronChannel.svelte'; - import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; - - export let workspace: ProjectWorkspace; - export let channel: WorkbenchChannel; - export let onInspect: (item: InspectorItem) => void; -</script> - -{#if channel.type === 'overview'} - <OverviewChannel {workspace} {onInspect} /> -{:else if channel.type === 'inbox'} - <InboxChannel messages={workspace.inbox} {onInspect} /> -{:else if channel.type === 'tasks'} - <TasksChannel tasks={workspace.tasks} {onInspect} /> -{:else if channel.type === 'ai_sessions'} - <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -{:else if channel.type === 'notes_sources'} - <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -{:else if channel.type === 'cron'} - <CronChannel plans={workspace.cronPlans} {onInspect} /> -{:else} - <CustomLinkChannel {channel} /> -{/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 -<script lang="ts"> - import type { InspectorItem, WorkTask } from '../types'; - export let tasks: WorkTask[]; - export let onInspect: (item: InspectorItem) => void; - - function inspectTask(task: WorkTask) { - onInspect({ - title: task.title, - type: 'Task', - description: task.summary, - properties: [ - { label: 'Status', value: task.completed ? 'Completed' : 'Open' }, - { label: 'Owner', value: task.owner }, - { label: 'Due', value: task.due }, - { label: 'Tag', value: task.tag }, - ], - }); - } -</script> - -<section class="channel-page"> - <header class="channel-header"> - <p>Work Plan</p> - <h1>Tasks</h1> - </header> - <div class="task-list"> - {#each tasks as task} - <article class:completed={task.completed} class="task-card"> - <span class="task-check" aria-hidden="true">{task.completed ? '鉁? : '鈼?}</span> - <div> - <h2>{task.title}</h2> - <p>{task.summary}</p> - <small>{task.owner} 路 {task.due} 路 #{task.tag}</small> - </div> - <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> - </article> - {/each} - </div> -</section> -``` - -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 -<script lang="ts"> - import type { InspectorItem } from './types'; - export let item: InspectorItem | null; - let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -</script> - -<aside class="object-inspector" aria-label="Object inspector"> - <div class="inspector-tabs" role="tablist" aria-label="Inspector tabs"> - <button aria-selected={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> - <button aria-selected={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> - <button aria-selected={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> - </div> - - {#if item} - <h2>{item.title}</h2> - <p>{item.description}</p> - {#if activeTab === 'discussion'} - <p>No comments yet. Add project discussion here later.</p> - {:else if activeTab === 'properties'} - <dl> - {#each item.properties as property} - <div> - <dt>{property.label}</dt> - <dd>{property.value}</dd> - </div> - {/each} - </dl> - {:else} - <button type="button">Copy link</button> - <button type="button">Archive</button> - {/if} - {:else} - <h2>Inspector</h2> - <p>Select an item to inspect discussion, properties, and actions.</p> - {/if} -</aside> -``` - -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 deleted file mode 100644 index e6c7290..0000000 --- a/.superpowers/sdd/task-4-review-package-v2.md +++ /dev/null @@ -1,524 +0,0 @@ -# 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 <title>` 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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel tasks={workspace.tasks} {onInspect} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs"> -+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button">Copy link</button> -+ <button type="button">Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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 @@ - <script lang="ts"> - import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; - import ProjectRail from './ProjectRail.svelte'; - import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; - import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem } from './types'; - - export let currentUser: { account: string }; - - let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; - $: workspace = getProjectWorkspace(selectedProjectID); - $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; - - function selectProject(projectID: number) { - selectedProjectID = projectID; - } - - function selectChannel(channelID: string) { - selectedChannelID = channelID; - } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } - </script> - - <main class="workbench-shell"> - <WorkspaceTopbar account={currentUser.account} /> - <div class="workbench-body"> - <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> - <ProjectChannelSidebar - project={workspace.project} - channels={workspace.channels} - {selectedChannelID} - tags={workspace.tags} - recentSessions={workspace.recentSessions} - onSelectChannel={selectChannel} - /> - <section class="channel-stage" aria-label="Channel content"> -- <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1> -+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> - </section> -- <aside class="object-inspector" aria-label="Object inspector"> -- <p>Select an item to inspect discussion, properties, and actions.</p> -- </aside> -+ <ObjectInspector item={selectedItem} /> - </div> - </main> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ -+ function copyUrl() { -+ void navigator.clipboard?.writeText(channel.url ?? ''); -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button type="button" on:click={copyUrl}>Copy URL</button> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span> -+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> diff --git a/.superpowers/sdd/task-4-review-package.md b/.superpowers/sdd/task-4-review-package.md deleted file mode 100644 index dae3f6c..0000000 --- a/.superpowers/sdd/task-4-review-package.md +++ /dev/null @@ -1,398 +0,0 @@ -# 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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; -+ import OverviewChannel from './channels/OverviewChannel.svelte'; -+ import InboxChannel from './channels/InboxChannel.svelte'; -+ import TasksChannel from './channels/TasksChannel.svelte'; -+ import AISessionsChannel from './channels/AISessionsChannel.svelte'; -+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; -+ import CronChannel from './channels/CronChannel.svelte'; -+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; -+ -+ export let workspace: ProjectWorkspace; -+ export let channel: WorkbenchChannel; -+ export let onInspect: (item: InspectorItem) => void; -+</script> -+ -+{#if channel.type === 'overview'} -+ <OverviewChannel {workspace} {onInspect} /> -+{:else if channel.type === 'inbox'} -+ <InboxChannel messages={workspace.inbox} {onInspect} /> -+{:else if channel.type === 'tasks'} -+ <TasksChannel tasks={workspace.tasks} {onInspect} /> -+{:else if channel.type === 'ai_sessions'} -+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> -+{:else if channel.type === 'notes_sources'} -+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> -+{:else if channel.type === 'cron'} -+ <CronChannel plans={workspace.cronPlans} {onInspect} /> -+{:else} -+ <CustomLinkChannel {channel} /> -+{/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 @@ -+<script lang="ts"> -+ import type { InspectorItem } from './types'; -+ -+ export let item: InspectorItem | null; -+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; -+</script> -+ -+<aside class="object-inspector" aria-label="Object inspector"> -+ <div class="inspector-tabs" role="tablist" aria-label="Inspector tabs"> -+ <button role="tab" aria-selected={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> -+ <button role="tab" aria-selected={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> -+ <button role="tab" aria-selected={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> -+ </div> -+ -+ {#if item} -+ <h2>{item.title}</h2> -+ <p>{item.description}</p> -+ {#if activeTab === 'discussion'} -+ <p>No comments yet. Add project discussion here later.</p> -+ {:else if activeTab === 'properties'} -+ <dl> -+ {#each item.properties as property} -+ <div> -+ <dt>{property.label}</dt> -+ <dd>{property.value}</dd> -+ </div> -+ {/each} -+ </dl> -+ {:else} -+ <button type="button">Copy link</button> -+ <button type="button">Archive</button> -+ {/if} -+ {:else} -+ <h2>Inspector</h2> -+ <p>Select an item to inspect discussion, properties, and actions.</p> -+ {/if} -+</aside> -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 @@ - <script lang="ts"> - import { getProjectWorkspace, workbenchProjects } from './mockData'; -+ import ChannelContent from './ChannelContent.svelte'; -+ import ObjectInspector from './ObjectInspector.svelte'; - import ProjectRail from './ProjectRail.svelte'; - import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; - import WorkspaceTopbar from './WorkspaceTopbar.svelte'; -+ import type { InspectorItem } from './types'; - - export let currentUser: { account: string }; - - let selectedProjectID = workbenchProjects[0].id; -+ let selectedItem: InspectorItem | null = null; - $: workspace = getProjectWorkspace(selectedProjectID); - $: selectedChannelID = workspace.channels[0].id; -+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; - - function selectProject(projectID: number) { - selectedProjectID = projectID; - } - - function selectChannel(channelID: string) { - selectedChannelID = channelID; - } -+ -+ function inspectItem(item: InspectorItem) { -+ selectedItem = item; -+ } - </script> - - <main class="workbench-shell"> - <WorkspaceTopbar account={currentUser.account} /> - <div class="workbench-body"> - <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> - <ProjectChannelSidebar - project={workspace.project} - channels={workspace.channels} - {selectedChannelID} - tags={workspace.tags} - recentSessions={workspace.recentSessions} - onSelectChannel={selectChannel} - /> - <section class="channel-stage" aria-label="Channel content"> -- <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1> -+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> - </section> -- <aside class="object-inspector" aria-label="Object inspector"> -- <p>Select an item to inspect discussion, properties, and actions.</p> -- </aside> -+ <ObjectInspector item={selectedItem} /> - </div> - </main> -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 @@ -+<script lang="ts"> -+ import type { AISessionItem, InspectorItem } from '../types'; -+ -+ export let sessions: AISessionItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ let selectedSessionID = sessions[0]?.id; -+ -+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; -+ -+ function inspectSession(session: AISessionItem) { -+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); -+ } -+</script> -+ -+<section class="channel-page ai-sessions-channel"> -+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> -+ <div class="session-layout"> -+ <div class="session-list"> -+ {#each sessions as session} -+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -+ {/each} -+ </div> -+ {#if selectedSession} -+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> -+ {/if} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { CronPlan, InspectorItem } from '../types'; -+ -+ export let plans: CronPlan[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectPlan(plan: CronPlan) { -+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); -+ } -+</script> -+ -+<section class="channel-page cron-channel"> -+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> -+ <div class="cron-list"> -+ {#each plans as plan} -+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { WorkbenchChannel } from '../types'; -+ -+ export let channel: WorkbenchChannel; -+ -+ function copyUrl() { -+ void navigator.clipboard?.writeText(channel.url ?? ''); -+ } -+</script> -+ -+<section class="channel-page custom-link-channel"> -+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> -+ <p>{channel.url}</p> -+ <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> -+ <button type="button" on:click={copyUrl}>Copy URL</button> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InboxMessage, InspectorItem } from '../types'; -+ -+ export let messages: InboxMessage[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectMessage(message: InboxMessage) { -+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); -+ } -+</script> -+ -+<section class="channel-page inbox-channel"> -+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> -+ <div class="message-list"> -+ {#each messages as message} -+ <article class="message-row"> -+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> -+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, NoteSourceItem } from '../types'; -+ -+ export let items: NoteSourceItem[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectItem(item: NoteSourceItem) { -+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); -+ } -+</script> -+ -+<section class="channel-page notes-sources-channel"> -+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> -+ <div class="file-list"> -+ {#each items as item} -+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, ProjectWorkspace } from '../types'; -+ -+ export let workspace: ProjectWorkspace; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ const metrics = (workspace: ProjectWorkspace) => [ -+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, -+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, -+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, -+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, -+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, -+ ]; -+</script> -+ -+<section class="channel-page overview-channel"> -+ <header class="channel-header"> -+ <p>{workspace.project.name}</p> -+ <h1>Overview</h1> -+ </header> -+ <div class="metric-list"> -+ {#each metrics(workspace) as metric} -+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> -+ <strong>{metric.value}</strong> -+ <span>{metric.label}</span> -+ </button> -+ {/each} -+ </div> -+</section> -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 @@ -+<script lang="ts"> -+ import type { InspectorItem, WorkTask } from '../types'; -+ -+ export let tasks: WorkTask[]; -+ export let onInspect: (item: InspectorItem) => void; -+ -+ function inspectTask(task: WorkTask) { -+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); -+ } -+</script> -+ -+<section class="channel-page tasks-channel"> -+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> -+ <div class="task-list"> -+ {#each tasks as task} -+ <article class:completed={task.completed} class="task-card"> -+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span> -+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div> -+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -+ </article> -+ {/each} -+ </div> -+</section> diff --git a/.superpowers/sdd/task-5-brief.md b/.superpowers/sdd/task-5-brief.md deleted file mode 100644 index 1767ae6..0000000 --- a/.superpowers/sdd/task-5-brief.md +++ /dev/null @@ -1,258 +0,0 @@ -## 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 deleted file mode 100644 index bc07d00..0000000 --- a/.superpowers/sdd/task-5-review-package-v2.md +++ /dev/null @@ -1,401 +0,0 @@ -# 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 deleted file mode 100644 index 78795ad..0000000 --- a/.superpowers/sdd/task-5-review-package.md +++ /dev/null @@ -1,350 +0,0 @@ -# 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 deleted file mode 100644 index 14c9381..0000000 --- a/.superpowers/sdd/task-6-brief.md +++ /dev/null @@ -1,106 +0,0 @@ -## 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 deleted file mode 100644 index 7b178dc..0000000 --- a/.superpowers/sdd/task-6-report.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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 deleted file mode 100644 index 6826a24..0000000 --- a/.superpowers/sdd/task-6-review-package.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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/README.md b/README.md index 9c73595..8ecf935 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,8 @@ Vite 默认地址为 `http://localhost:5173`。登录页的服务器地址默认 ```powershell $repoRoot = (git rev-parse --show-toplevel).Trim() Set-Location (Join-Path $repoRoot 'backend') -go test ./... +go test -count=1 ./... +go vet ./... ``` Web: @@ -164,15 +165,16 @@ npm run build npm run lint ``` -如果设置了指向隔离测试库的 `DATABASE_URL`,再运行 PostgreSQL 集成检查: +如果设置了指向隔离测试库的专用 `TEST_DATABASE_URL`,再运行完整 PostgreSQL 集成测试套件: ```powershell $repoRoot = (git rev-parse --show-toplevel).Trim() Set-Location (Join-Path $repoRoot 'backend') -go test -tags integration ./internal/models -run TestPostgresPing -v +$env:TEST_DATABASE_URL = '<隔离测试库连接串>' +go test -count=1 -tags=integration ./... ``` -不得把真实测试数据库凭据写入仓库。当前机器的逐项执行结果与产物路径见 [MVP 验证说明](docs/mvp-verification.md)。 +不得把真实测试数据库凭据写入仓库。带 `integration` tag 的测试在缺少 `TEST_DATABASE_URL` 时会明确失败,且不会读取运行环境的 `DATABASE_URL`。当前机器的逐项执行结果与产物路径见 [MVP 验证说明](docs/mvp-verification.md)。 ## 桌面端 @@ -226,5 +228,5 @@ npm run bundle - AI 分析和会话不会自动创建正式任务、笔记或资料。Inbox 只有在用户确认服务端保存的建议后才创建对象,并保留来源 Inbox ID。 - 项目标签只在项目内有效。任务移动、标签写入和文件上传都校验项目所有权。 - 任务分享遵循最小可见范围:被指派人只能看到任务以及被显式分享的关联对象;分享的 note/source 必须与任务属于同一项目。 -- 文件存储路径只由文件服务构造,上传在写磁盘前校验所有权和请求体大小。 +- 文件存储路径只由文件服务构造,上传在写磁盘前校验所有权和请求体大小;公开 DTO 仅返回不透明存储键,不暴露物理路径。 - 生产部署必须替换所有开发凭据和密钥、限制 CORS 来源、使用独立数据库与受控存储目录,并通过 HTTPS 暴露服务。 diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6bf3e63..744ed8c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "senlinai_workbench" version = "0.1.0" -description = "SenlinAI project workbench desktop shell" -authors = ["SenlinAI"] +description = "森林AI 项目工作台桌面壳" +authors = ["森林AI"] edition = "2021" [lib] diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 71d7b61..c1c9b1e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "SenlinAI Workbench", + "productName": "森林AI", "version": "0.1.0", "identifier": "ai.senlin.workbench", "build": { @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "项目工作台", + "title": "森林AI", "width": 1280, "height": 820 } diff --git a/apps/web_v1/README.md b/apps/web_v1/README.md index c086495..54ab04c 100644 --- a/apps/web_v1/README.md +++ b/apps/web_v1/README.md @@ -1,17 +1,19 @@ -# SenlinAI Acro React +# 森林AI Web 客户端 -SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design. +森林AI 当前主 Web 客户端,使用 React、TypeScript、Vite 和 Arco Design,目录为 `apps/web_v1`。 -This app is the active web client under `apps/web_v1`. +开发服务器默认运行在 `http://localhost:5173`,登录页默认连接 `http://localhost:9150` 的 API;两者都可按私有部署环境调整。 -## Scripts +## 开发与验证 ```bash -npm install +npm ci npm run dev +node --test scripts/api-client.test.mjs scripts/workspace-refresh-gate.test.mjs +node scripts/structure-check.mjs +node scripts/visual-check.mjs npm run build npm run lint -node scripts/visual-check.mjs ``` -Default local preview port: `4174`. +视觉检查使用临时 Vite 服务,默认端口为 `4175`;可通过 `VISUAL_CHECK_PORT` 覆盖。`dist/` 与 `test-results/` 都是本地产物,不应提交。 diff --git a/docs/forest-ai-audit.md b/docs/forest-ai-audit.md index 5e8985b..913207c 100644 --- a/docs/forest-ai-audit.md +++ b/docs/forest-ai-audit.md @@ -1,6 +1,6 @@ # 森林AI MVP 最终验证与视觉审计 -审计日期:2026-07-21 +审计日期:2026-07-22 ## 结论 @@ -19,10 +19,13 @@ | 1440×1024 | `project-ai-controlled.png` | 会话列表、受控创建入口和“不会自动生成正式对象”说明可见 | | 1440×1024 | `project-inbox-confirmed.png` | 收集内容、分析建议、选择项、确认结果和不可重复写入状态可见 | | 390×844 | `login-react-acro-mobile.png` | 单栏布局,无水平裁切;首屏下方内容可纵向滚动 | -| 390×844 | `project-react-acro-mobile.png` | 默认关闭导航后主内容可访问;标题正常横排、URL 位于视口内,指标以 2×2 排列 | -| 390×844 | `project-navigation-mobile.png` | 项目抽屉、遮罩与关闭路径可见 | +| 1024×768 | `channel-navigation-tablet-1024.png` | 频道侧栏默认收起,可从项目轨道右侧打开抽屉 | +| 390×844 | `project-react-acro-mobile.png` | 默认关闭导航后主内容可访问;标题和项目标识位于视口内,指标以 2×2 排列 | +| 390×844 | `workbench-mobile-search.png` | 移动端保留可打开、可关闭的全局搜索入口 | +| 390×844 | `project-navigation-mobile.png` | 13 个项目可在独立区域滚动,新建项目入口保持可见 | | 390×844 | `channel-navigation-mobile.png` | 频道抽屉、最近会话与关闭路径可见 | | 390×844 | `project-inbox-confirmed-mobile.png` | Inbox 列表和详情按单列重排,无横向溢出 | +| 1440×1024 | `project-inbox-empty-capture-analysis.png` | 空 Inbox 从真实收集表单进入刷新与分析流程 | 视觉脚本在桌面切换到移动视口后,会等待项目轨道与频道侧栏真正进入隐藏状态再采集项目主内容,避免把 160ms 收起动画的中间帧误当成最终证据。 @@ -35,7 +38,7 @@ - 项目页取消常驻右侧检查器,布局边界变为项目轨道、频道侧栏和主内容三段连续结构;项目强调色集中在横幅和小型标记。 - 任务页从静态示例卡片改为后端工作区数据驱动的标签、计数和空状态。 - 390px 项目页不再是桌面布局左侧切片;主内容默认可见,项目与频道分别通过顶部按钮打开抽屉。 -- 390px 项目概览标题保持正常横排,项目 URL 保持在容器内并可在必要时省略,四项指标使用 2×2 网格并保持标签、数值和说明横向可读。 +- 390px 项目概览标题保持正常横排,持久化项目标识保持在容器内,四项指标使用 2×2 网格并保持标签、数值和说明横向可读。 - Inbox 和 AI 页面明确保留“分析/受控会话 → 用户确认 → 正式对象”的边界,不把 AI 输出表现为已经自动执行。 ## 核心交互与安全边界 @@ -46,7 +49,9 @@ - 项目/任务/笔记关键词搜索;较慢的旧响应不能覆盖较新的结果。 - 被授权的外部任务或笔记只打开受限预览,不加载或切换到无所有权的项目工作区;未知结果类型不伪造项目导航。 - 项目设置更新包含写入中状态、成功刷新和中文失败提示。 +- 项目设置失败时保留原编辑草稿;截图 `project-mutation-failure-draft-retained.png` 记录该状态。 - Inbox 分析只读取服务端保存的建议;确认只发送勾选的建议 identity。网络结果不确定时冻结选择并复用原请求安全重试,确定性 400、409 冲突和写入后刷新失败分别处理。 +- 自定义链接频道明确显示只读“暂未开放”,计划任务只展示服务端持久化元数据且不提供伪开关或固定成功结果。 - AI 会话创建只发送标题和项目上下文;同项目乱序 GET、跨项目延迟 POST 和项目切换后的加载失败不会污染当前项目状态。 - 探索数据源、自定义频道/外链、升级支付、窗口停靠和自主 Agent 等范围外能力不呈现为可成功执行的控件。 @@ -54,7 +59,7 @@ ## 剩余限制 -- PostgreSQL 测试带 `integration` build tag,未带 tag 时不会被编译。当前 `DATABASE_DSN` 与 `DATABASE_URL` 均未配置,因此本轮未运行 tagged live check;在隔离测试库配置其一后,应运行 `go test -tags=integration ./internal/models -run TestPostgresPing -v`。缺少两个变量时该测试会失败,而不是跳过。 +- PostgreSQL 行为测试带 `integration` build tag,未带 tag 时不会编译进普通测试。只有设置隔离数据库专用的 `TEST_DATABASE_URL` 后,才运行完整套件 `go test -count=1 -tags=integration ./...`;缺少该变量时 tagged 测试会明确失败,且不会回退读取 `DATABASE_URL`。 - 当前环境未安装 Docker CLI,未执行 Compose 启动、数据库初始化或容器内端到端检查。 - Web 构建成功,但主 JavaScript chunk 约 663 kB、主 CSS chunk 约 592 kB,Vite 报告大于 500 kB 的体积警告;这是性能优化项,不阻塞 MVP 正确性验收。 - 桌面壳构建成功;Windows 链接器输出了导入库创建信息并被 Rust 记录为一条 linker warning,没有构建错误。 diff --git a/docs/mvp-verification.md b/docs/mvp-verification.md index 633829c..b8f909f 100644 --- a/docs/mvp-verification.md +++ b/docs/mvp-verification.md @@ -1,6 +1,6 @@ # 森林AI MVP 验证 -本文记录 2026-07-21 在 Windows、PowerShell 环境中对当前仓库实际执行的验证。只有获得成功退出码或明确 HTTP 响应的命令列为通过;未满足环境条件的项目单独列出,不视为通过。 +本文记录 2026-07-22 在 Windows、PowerShell 环境中对当前仓库实际执行的验证。只有获得成功退出码或明确 HTTP 响应的命令列为通过;未满足环境条件的项目单独列出,不视为通过。 ## 环境基线 @@ -10,7 +10,7 @@ - Rust `1.97.1` - Cargo `1.97.1` - Docker CLI:当前机器不可用 -- `DATABASE_URL`:当前 shell 未设置 +- `TEST_DATABASE_URL`:当前 shell 未设置 本地 Compose 与 `backend/etc/agent.dev.yaml` 约定 PostgreSQL 监听 `localhost:5432`,API 监听 `localhost:9150`,Vite 默认监听 `localhost:5173`。Compose 和开发配置中的固定数据库凭据只供本地开发,生产环境必须替换。 @@ -31,7 +31,7 @@ | 桌面端依赖安装 | 通过 | `apps/desktop` 下 `npm ci` 成功。 | | Tauri 可执行文件构建 | 通过 | 冷构建超过单次 240 秒工具等待上限,但已完成 release 编译;同一命令增量重跑 58.3 秒退出 `0`。 | | Tauri 安装包 | 未验证 | 本次未运行 `npm run bundle`。 | -| PostgreSQL 集成测试 | 未运行 | 当前 shell 未提供 `DATABASE_URL`。 | +| PostgreSQL 集成测试 | 未运行 | 当前 shell 未提供隔离数据库专用的 `TEST_DATABASE_URL`。 | ## 本地启动检查 @@ -111,11 +111,11 @@ go test -count=1 ./... ```powershell $repoRoot = (git rev-parse --show-toplevel).Trim() Set-Location (Join-Path $repoRoot 'backend') -$env:DATABASE_URL = '<隔离测试库连接串>' -go test -tags integration ./internal/models -run TestPostgresPing -v +$env:TEST_DATABASE_URL = '<隔离测试库连接串>' +go test -count=1 -tags=integration ./... ``` -连接串必须通过安全的本地或 CI secret 注入,不能提交到仓库。本次 `DATABASE_URL` 未设置,因此没有运行,也不列为通过。 +连接串必须通过安全的本地或 CI secret 注入,不能提交到仓库。Tagged 测试不会读取运行环境的 `DATABASE_URL`;缺少 `TEST_DATABASE_URL` 时会明确失败。本次未设置该变量,因此没有运行实时数据库测试,也不列为通过。 ### Web @@ -128,7 +128,7 @@ npm run lint node scripts/structure-check.mjs ``` -四条命令本次均退出 `0`。视觉检查覆盖桌面和移动登录页、工作台、项目导航、Inbox 确认、AI 会话受控创建、深色模式、无横向溢出和无未预期 console error。 +四条命令本次均退出 `0`。视觉检查覆盖桌面和移动登录页、1024px 频道抽屉、390px 搜索与项目抽屉、13 项目滚动、空 Inbox 收集到分析、Inbox 确认、失败草稿保留、AI 会话受控创建、深色模式、无横向溢出和无未预期 console error。 `npm run build` 生成: @@ -145,6 +145,11 @@ node scripts/structure-check.mjs - `project-react-acro-dark.png` - `project-navigation-mobile.png` - `channel-navigation-mobile.png` +- `channel-navigation-tablet-1024.png` +- `workbench-mobile-search.png` +- `project-inbox-empty-capture-analysis.png` +- `project-mutation-failure-draft-retained.png` +- `project-custom-link-unavailable.png` - `project-inbox-confirmed.png` - `project-inbox-confirmed-mobile.png` - `project-ai-controlled.png` @@ -200,7 +205,7 @@ npm run bundle 2. 使用仅供开发的演示账号登录,并确认生产部署未使用默认凭据。 3. 创建项目,修改项目设置,再刷新确认数据保留。 4. 创建项目内标签和任务,验证任务卡显示真实标签与状态。 -5. 上传文件资料,确认返回路径是相对存储路径,且越权项目不能留下文件。 +5. 上传文件资料,确认公开响应只包含不透明存储键、不泄露物理路径,且越权项目不能留下文件。 6. 创建计划任务元数据,确认界面没有暗示自主 Agent 已执行。 7. 收集一条 Inbox 内容,先分析建议;此时不应出现正式对象。 8. 选择一条或多条服务端建议并确认,验证生成对象保留来源 Inbox ID。 @@ -218,7 +223,7 @@ npm run bundle - AI 输出只形成待确认草稿;用户确认前不创建任务、笔记或资料。 - 项目标签保持项目内范围;任务分享的 note/source 与任务属于同一项目。 - 被指派人只看到任务和明确分享的关联对象。 -- PostgreSQL 集成测试只使用显式 `DATABASE_URL` 指向的隔离测试库。 +- PostgreSQL 集成测试只使用显式 `TEST_DATABASE_URL` 指向的隔离测试库,不读取运行环境的 `DATABASE_URL`。 ## 文档一致性检查