diff --git a/.superpowers/sdd/final-review-package-v2.md b/.superpowers/sdd/final-review-package-v2.md new file mode 100644 index 0000000..02e8b86 --- /dev/null +++ b/.superpowers/sdd/final-review-package-v2.md @@ -0,0 +1,1825 @@ +# Final Review Package v2 + +## Base +4fc9271 + +## Head +43d0b9f0497e10f0577907fe7dbf60a5afa47916 + +## Commits +43d0b9f chore: remove sdd scratch artifact +ba4ca16 fix: resolve workbench final review findings +6341a6b test: cover project channel workbench flow +7086b8b fix: style active inspector view +092930e style: apply workbench visual system +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates +95dce81 feat: add project channel workbench shell +6c12d4b feat: add project workspace data model +5b95668 feat: add full workbench login page + +## Stat + .superpowers/sdd/task-2-report.md | 44 +++ + .superpowers/sdd/task-4-report.md | 67 ++++ + .superpowers/sdd/task-5-report.md | 43 +++ + apps/web/e2e/project-workbench.spec.ts | 50 ++- + apps/web/src/app/App.svelte | 77 +---- + apps/web/src/features/auth/ServerLogin.svelte | 90 ++++- + apps/web/src/features/auth/ServerLogin.test.ts | 66 ++++ + .../src/features/workbench/ChannelContent.svelte | 30 ++ + .../src/features/workbench/ChannelContent.test.ts | 64 ++++ + .../src/features/workbench/ObjectInspector.svelte | 37 +++ + .../workbench/ProjectChannelSidebar.svelte | 67 ++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ + .../src/features/workbench/ProjectWorkbench.svelte | 50 +++ + .../features/workbench/ProjectWorkbench.test.ts | 72 ++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 + + .../workbench/channels/AISessionsChannel.svelte | 27 ++ + .../features/workbench/channels/CronChannel.svelte | 19 ++ + .../workbench/channels/CustomLinkChannel.svelte | 25 ++ + .../workbench/channels/InboxChannel.svelte | 22 ++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 ++ + .../workbench/channels/OverviewChannel.svelte | 29 ++ + .../workbench/channels/TasksChannel.svelte | 46 +++ + apps/web/src/features/workbench/mockData.test.ts | 29 ++ + apps/web/src/features/workbench/mockData.ts | 61 ++++ + apps/web/src/features/workbench/types.ts | 93 ++++++ + apps/web/src/index.css | 362 +++++++++++++++------ + 26 files changed, 1337 insertions(+), 194 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..48a37cf +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,44 @@ ++# Task 2 Report: Workbench Types And Project-Scoped Mock Data ++ ++## What changed ++ ++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`. ++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`. ++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`. ++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. ++ ++## Tests ++ ++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed. ++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed. ++- Web production build: `npm run build` -> successful. ++- Formatting/diff validation: `git diff --check` -> no issues. ++ ++## TDD evidence ++ ++1. Created `mockData.test.ts` before production implementation. ++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist. ++3. Added `types.ts` and `mockData.ts` using the exact task brief values. ++4. Re-ran the targeted test and observed green: 2/2 tests passed. ++5. Ran the full Web test suite and build successfully. ++ ++## Files changed ++ ++- `apps/web/src/features/workbench/types.ts` ++- `apps/web/src/features/workbench/mockData.ts` ++- `apps/web/src/features/workbench/mockData.test.ts` ++- `.superpowers/sdd/task-2-report.md` ++ ++## Self-review ++ ++- `ChannelType` contains all seven required channel literals in the specified order. ++- `ProjectWorkspace` includes all required project-scoped collections. ++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. ++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. ++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. ++- No unrelated tracked files were changed. ++ ++## Concerns ++ ++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding. ++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. +diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md +new file mode 100644 +index 0000000..b743d8d +--- /dev/null ++++ b/.superpowers/sdd/task-4-report.md +@@ -0,0 +1,67 @@ ++# Task 4 Report: Channel Content Templates And Object Inspector ++ ++## Status ++ ++Implemented and committed the Task 4 workbench channel templates and object inspector. ++ ++## What Changed ++ ++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. ++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. ++- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench. ++- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties. ++- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`. ++- Added the required channel dispatcher test, including the task-to-inspector callback assertion. ++- Kept visual-system CSS untouched for Task 5. ++ ++## TDD Evidence ++ ++1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component. ++2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`. ++3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist. ++4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests. ++5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings. ++ ++## Tests ++ ++- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts` ++ - Passed: 2 test files, 4 tests. ++- `npm run build` ++ - Passed: Vite production build completed successfully. ++- `git diff --check` ++ - Passed: no whitespace errors. ++ ++## Files Changed ++ ++- Created `apps/web/src/features/workbench/ChannelContent.svelte` ++- Created `apps/web/src/features/workbench/ChannelContent.test.ts` ++- Created `apps/web/src/features/workbench/ObjectInspector.svelte` ++- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/CronChannel.svelte` ++- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte` ++- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte` ++- Created this report: `.superpowers/sdd/task-4-report.md` ++ ++## Self-Review ++ ++- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher. ++- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action. ++- Confirmed task inspection produces the exact task title required by the test. ++- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`. ++- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced. ++- Confirmed unrelated existing `.superpowers/sdd` files were not staged. ++ ++## Concerns ++ ++- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data. ++- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work. ++ ++## Review Fixes ++ ++- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model. ++- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector. ++- Focused workbench coverage passed after the changes: 2 test files, 5 tests. +diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md +new file mode 100644 +index 0000000..d4fc752 +--- /dev/null ++++ b/.superpowers/sdd/task-5-report.md +@@ -0,0 +1,43 @@ ++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS ++ ++## Status ++ ++Implemented and committed the Task 5 workbench visual system. ++ ++## What Changed ++ ++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles. ++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`. ++ ++## Tests ++ ++From `apps/web`: ++ ++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests. ++- `npm test -- --run` passed: 6 test files, 13 tests. ++- `npm run build` passed: Vite production build completed successfully. ++ ++## TDD Evidence ++ ++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component. ++ ++## Files Changed ++ ++- `apps/web/src/index.css` ++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts` ++ ++## Self-Review ++ ++- CSS values and selectors follow the Task 5 brief verbatim. ++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px. ++- Keyboard focus is visibly indicated for buttons and inputs. ++- No React or shadcn/ui dependencies were added. ++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched. ++ ++## Concerns ++ ++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below. ++ ++## Review Fix ++ ++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics. +diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts +index 8a2358a..08b02ee 100644 +--- a/apps/web/e2e/project-workbench.spec.ts ++++ b/apps/web/e2e/project-workbench.spec.ts +@@ -1,17 +1,49 @@ + import { expect, test } from '@playwright/test'; + +-test('project workbench shell renders', async ({ page }) => { ++test('login and project channel workbench flow', async ({ page }) => { + await page.goto('/'); +- await expect(page.getByText('项目工作台')).toBeVisible(); +- await expect(page.getByLabel('服务器登录')).toBeVisible(); +- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible(); +- await expect(page.getByLabel('项目总览')).toBeVisible(); ++ ++ await expect(page.getByLabel('Server IP or domain')).toBeVisible(); ++ await page.getByLabel('Server IP or domain').fill('localhost:8080'); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ await expect(page.getByLabel('Project list')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Work Plan 8' }).click(); ++ await expect(page.getByText('Tasks')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Project A2' }).click(); ++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByText('Ops Dashboard')).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Ops Dashboard' }).click(); ++ await expect(page.getByText('Open external channel')).toBeVisible(); + }); + +-test('inbox suggestions require explicit confirmation', async ({ page }) => { ++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => { ++ await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); +- await page.getByLabel('选择 跟进报价').check(); +- await page.getByRole('button', { name: '创建选中项' }).click(); + +- await expect(page.getByText('已选择 1 项')).toBeVisible(); ++ await page.getByLabel('Server IP or domain').fill('localhost:8080'); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ const rail = page.getByLabel('Project list'); ++ const sidebar = page.getByLabel('Project channels'); ++ const stage = page.getByLabel('Channel content'); ++ const inspector = page.getByLabel('Object inspector'); ++ ++ await expect(rail).toHaveCSS('grid-auto-flow', 'column'); ++ await expect(rail).toHaveCSS('max-height', '76px'); ++ await expect(inspector).toHaveCSS('max-height', '288px'); ++ ++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]); ++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0); ++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248); + }); +diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte +index a4c6389..e7170ef 100644 +--- a/apps/web/src/app/App.svelte ++++ b/apps/web/src/app/App.svelte +@@ -1,76 +1,19 @@ + <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 new file mode 100644 index 0000000..797cb55 --- /dev/null +++ b/.superpowers/sdd/final-review-package-v3.md @@ -0,0 +1,1952 @@ +# Final Review Package v3 + +## Base +4fc9271 + +## Head +e289b1dbb0998d749bcbfb9746c53a8827515154 + +## Commits +e289b1d chore: remove final sdd scratch artifact +27ff659 fix: resolve final workbench review findings +43d0b9f chore: remove sdd scratch artifact +ba4ca16 fix: resolve workbench final review findings +6341a6b test: cover project channel workbench flow +7086b8b fix: style active inspector view +092930e style: apply workbench visual system +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates +95dce81 feat: add project channel workbench shell +6c12d4b feat: add project workspace data model +5b95668 feat: add full workbench login page + +## Stat + .superpowers/sdd/task-2-report.md | 44 +++ + .superpowers/sdd/task-4-report.md | 67 ++++ + .superpowers/sdd/task-5-report.md | 43 +++ + apps/web/e2e/project-workbench.spec.ts | 68 +++- + apps/web/src/app/App.svelte | 77 +--- + apps/web/src/features/auth/ServerLogin.svelte | 112 +++++- + apps/web/src/features/auth/ServerLogin.test.ts | 72 ++++ + .../src/features/workbench/ChannelContent.svelte | 32 ++ + .../src/features/workbench/ChannelContent.test.ts | 64 ++++ + .../src/features/workbench/ObjectInspector.svelte | 53 +++ + .../workbench/ProjectChannelSidebar.svelte | 67 ++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ + .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ + .../features/workbench/ProjectWorkbench.test.ts | 102 ++++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 + + .../workbench/channels/AISessionsChannel.svelte | 27 ++ + .../features/workbench/channels/CronChannel.svelte | 19 + + .../workbench/channels/CustomLinkChannel.svelte | 25 ++ + .../workbench/channels/InboxChannel.svelte | 22 ++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 + + .../workbench/channels/OverviewChannel.svelte | 29 ++ + .../workbench/channels/TasksChannel.svelte | 36 ++ + apps/web/src/features/workbench/mockData.test.ts | 29 ++ + apps/web/src/features/workbench/mockData.ts | 61 ++++ + apps/web/src/features/workbench/types.ts | 93 +++++ + apps/web/src/index.css | 391 +++++++++++++++------ + 26 files changed, 1462 insertions(+), 193 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..48a37cf +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,44 @@ ++# Task 2 Report: Workbench Types And Project-Scoped Mock Data ++ ++## What changed ++ ++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`. ++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`. ++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`. ++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. ++ ++## Tests ++ ++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed. ++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed. ++- Web production build: `npm run build` -> successful. ++- Formatting/diff validation: `git diff --check` -> no issues. ++ ++## TDD evidence ++ ++1. Created `mockData.test.ts` before production implementation. ++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist. ++3. Added `types.ts` and `mockData.ts` using the exact task brief values. ++4. Re-ran the targeted test and observed green: 2/2 tests passed. ++5. Ran the full Web test suite and build successfully. ++ ++## Files changed ++ ++- `apps/web/src/features/workbench/types.ts` ++- `apps/web/src/features/workbench/mockData.ts` ++- `apps/web/src/features/workbench/mockData.test.ts` ++- `.superpowers/sdd/task-2-report.md` ++ ++## Self-review ++ ++- `ChannelType` contains all seven required channel literals in the specified order. ++- `ProjectWorkspace` includes all required project-scoped collections. ++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. ++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. ++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. ++- No unrelated tracked files were changed. ++ ++## Concerns ++ ++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding. ++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. +diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md +new file mode 100644 +index 0000000..b743d8d +--- /dev/null ++++ b/.superpowers/sdd/task-4-report.md +@@ -0,0 +1,67 @@ ++# Task 4 Report: Channel Content Templates And Object Inspector ++ ++## Status ++ ++Implemented and committed the Task 4 workbench channel templates and object inspector. ++ ++## What Changed ++ ++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. ++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. ++- Made all applicable records selectable through an `Inspect <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 new file mode 100644 index 0000000..1abd6ca --- /dev/null +++ b/.superpowers/sdd/final-review-package-v4.md @@ -0,0 +1,2162 @@ +# Final Review Package v4 + +## Base +4fc9271 + +## Head +ec59dee6b9b34c4cae4b3179fba0128ff37bb7c0 + +## Commits +ec59dee chore: remove final review scratch report +2d82705 fix: resolve final workbench re-review findings +e289b1d chore: remove final sdd scratch artifact +27ff659 fix: resolve final workbench review findings +43d0b9f chore: remove sdd scratch artifact +ba4ca16 fix: resolve workbench final review findings +6341a6b test: cover project channel workbench flow +7086b8b fix: style active inspector view +092930e style: apply workbench visual system +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates +95dce81 feat: add project channel workbench shell +6c12d4b feat: add project workspace data model +5b95668 feat: add full workbench login page + +## Stat + .superpowers/sdd/task-2-report.md | 44 +++ + .superpowers/sdd/task-4-report.md | 67 ++++ + .superpowers/sdd/task-5-report.md | 43 +++ + apps/web/e2e/project-workbench.spec.ts | 70 +++- + apps/web/src/app/App.svelte | 77 +--- + apps/web/src/features/auth/ServerLogin.svelte | 136 ++++++- + apps/web/src/features/auth/ServerLogin.test.ts | 96 +++++ + .../src/features/workbench/ChannelContent.svelte | 32 ++ + .../src/features/workbench/ChannelContent.test.ts | 64 ++++ + .../src/features/workbench/ObjectInspector.svelte | 67 ++++ + .../src/features/workbench/ObjectInspector.test.ts | 73 ++++ + .../workbench/ProjectChannelSidebar.svelte | 67 ++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ + .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ + .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 + + .../workbench/channels/AISessionsChannel.svelte | 27 ++ + .../features/workbench/channels/CronChannel.svelte | 19 + + .../workbench/channels/CustomLinkChannel.svelte | 34 ++ + .../workbench/channels/CustomLinkChannel.test.ts | 36 ++ + .../workbench/channels/InboxChannel.svelte | 22 ++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 + + .../workbench/channels/OverviewChannel.svelte | 29 ++ + .../workbench/channels/TasksChannel.svelte | 36 ++ + apps/web/src/features/workbench/mockData.test.ts | 29 ++ + apps/web/src/features/workbench/mockData.ts | 61 ++++ + apps/web/src/features/workbench/types.ts | 93 +++++ + apps/web/src/index.css | 392 +++++++++++++++------ + 28 files changed, 1656 insertions(+), 193 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..48a37cf +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,44 @@ ++# Task 2 Report: Workbench Types And Project-Scoped Mock Data ++ ++## What changed ++ ++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`. ++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`. ++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`. ++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. ++ ++## Tests ++ ++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed. ++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed. ++- Web production build: `npm run build` -> successful. ++- Formatting/diff validation: `git diff --check` -> no issues. ++ ++## TDD evidence ++ ++1. Created `mockData.test.ts` before production implementation. ++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist. ++3. Added `types.ts` and `mockData.ts` using the exact task brief values. ++4. Re-ran the targeted test and observed green: 2/2 tests passed. ++5. Ran the full Web test suite and build successfully. ++ ++## Files changed ++ ++- `apps/web/src/features/workbench/types.ts` ++- `apps/web/src/features/workbench/mockData.ts` ++- `apps/web/src/features/workbench/mockData.test.ts` ++- `.superpowers/sdd/task-2-report.md` ++ ++## Self-review ++ ++- `ChannelType` contains all seven required channel literals in the specified order. ++- `ProjectWorkspace` includes all required project-scoped collections. ++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. ++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. ++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. ++- No unrelated tracked files were changed. ++ ++## Concerns ++ ++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding. ++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. +diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md +new file mode 100644 +index 0000000..b743d8d +--- /dev/null ++++ b/.superpowers/sdd/task-4-report.md +@@ -0,0 +1,67 @@ ++# Task 4 Report: Channel Content Templates And Object Inspector ++ ++## Status ++ ++Implemented and committed the Task 4 workbench channel templates and object inspector. ++ ++## What Changed ++ ++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. ++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. ++- Made all applicable records selectable through an `Inspect <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 new file mode 100644 index 0000000..527057c --- /dev/null +++ b/.superpowers/sdd/final-review-package-v5.md @@ -0,0 +1,2044 @@ +# Final Review Package v5 + +## Base +4fc9271 + +## Head +1fcec3e0b5f09ef238a0596344f8d478ee1e55c4 + +## Commits +1fcec3e chore: remove sdd task reports +7b1bbf1 chore: remove last sdd scratch report +884148b fix web review validation and clipboard tests +ec59dee chore: remove final review scratch report +2d82705 fix: resolve final workbench re-review findings +e289b1d chore: remove final sdd scratch artifact +27ff659 fix: resolve final workbench review findings +43d0b9f chore: remove sdd scratch artifact +ba4ca16 fix: resolve workbench final review findings +6341a6b test: cover project channel workbench flow +7086b8b fix: style active inspector view +092930e style: apply workbench visual system +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates +95dce81 feat: add project channel workbench shell +6c12d4b feat: add project workspace data model +5b95668 feat: add full workbench login page + +## Stat + apps/web/e2e/project-workbench.spec.ts | 80 ++++- + apps/web/src/app/App.svelte | 77 +--- + apps/web/src/features/auth/ServerLogin.svelte | 140 +++++++- + apps/web/src/features/auth/ServerLogin.test.ts | 102 ++++++ + .../src/features/workbench/ChannelContent.svelte | 32 ++ + .../src/features/workbench/ChannelContent.test.ts | 64 ++++ + .../src/features/workbench/ObjectInspector.svelte | 67 ++++ + .../src/features/workbench/ObjectInspector.test.ts | 73 ++++ + .../workbench/ProjectChannelSidebar.svelte | 67 ++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ + .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ + .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 + + .../workbench/channels/AISessionsChannel.svelte | 27 ++ + .../features/workbench/channels/CronChannel.svelte | 19 + + .../workbench/channels/CustomLinkChannel.svelte | 34 ++ + .../workbench/channels/CustomLinkChannel.test.ts | 70 ++++ + .../workbench/channels/InboxChannel.svelte | 22 ++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 + + .../workbench/channels/OverviewChannel.svelte | 29 ++ + .../workbench/channels/TasksChannel.svelte | 36 ++ + apps/web/src/features/workbench/mockData.test.ts | 29 ++ + apps/web/src/features/workbench/mockData.ts | 61 ++++ + apps/web/src/features/workbench/types.ts | 93 +++++ + apps/web/src/index.css | 392 +++++++++++++++------ + 25 files changed, 1556 insertions(+), 193 deletions(-) + +## Diff +diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts +index 8a2358a..1b67f8f 100644 +--- a/apps/web/e2e/project-workbench.spec.ts ++++ b/apps/web/e2e/project-workbench.spec.ts +@@ -1,17 +1,79 @@ + import { expect, test } from '@playwright/test'; + +-test('project workbench shell renders', async ({ page }) => { ++test('login and project channel workbench flow', async ({ page }) => { + await page.goto('/'); +- await expect(page.getByText('项目工作台')).toBeVisible(); +- await expect(page.getByLabel('服务器登录')).toBeVisible(); +- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible(); +- await expect(page.getByLabel('项目总览')).toBeVisible(); ++ ++ await expect(page.getByLabel('Server IP or domain')).toBeVisible(); ++ await page.getByLabel('Server IP or domain').fill('localhost:8080'); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ await expect(page.getByLabel('Project list')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Work Plan 8' }).click(); ++ await expect(page.getByText('Tasks')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Project A2' }).click(); ++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByText('Ops Dashboard')).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Ops Dashboard' }).click(); ++ await expect(page.getByText('Open external channel')).toBeVisible(); + }); + +-test('inbox suggestions require explicit confirmation', async ({ page }) => { ++for (const server of [ ++ 'https://user:secret@example.com', ++ 'http:////example.com', ++ 'http//example.com', ++ 'https//example.com', ++ 'http:/example.com', ++ 'https:/example.com', ++ 'http://example.com\\path', ++ 'http://example..com', ++ 'http://-bad.com', ++]) { ++ test(`rejects malformed server address: ${server}`, async ({ page }) => { ++ await page.goto('/'); ++ ++ await page.getByLabel('Server IP or domain').fill(server); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible(); ++ await expect(page.getByLabel('Server IP or domain')).toBeVisible(); ++ }); ++} ++ ++test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => { ++ await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); +- await page.getByLabel('选择 跟进报价').check(); +- await page.getByRole('button', { name: '创建选中项' }).click(); + +- await expect(page.getByText('已选择 1 项')).toBeVisible(); ++ await page.getByLabel('Server IP or domain').fill('localhost:8080'); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ const rail = page.getByLabel('Project list'); ++ const sidebar = page.getByLabel('Project channels'); ++ const stage = page.getByLabel('Channel content'); ++ const inspector = page.getByLabel('Object inspector'); ++ ++ await expect(rail).toHaveCSS('grid-auto-flow', 'column'); ++ await expect(rail).toHaveCSS('max-height', '76px'); ++ await expect(inspector).toHaveCSS('max-height', '288px'); ++ ++ const workPlan = page.getByRole('button', { name: 'Work Plan 8' }); ++ await expect(workPlan).toBeVisible(); ++ await expect(workPlan).toBeInViewport(); ++ await workPlan.click(); ++ await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible(); ++ ++ const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]); ++ expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0); ++ expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248); + }); +diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte +index a4c6389..e7170ef 100644 +--- a/apps/web/src/app/App.svelte ++++ b/apps/web/src/app/App.svelte +@@ -1,76 +1,19 @@ + <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 new file mode 100644 index 0000000..ac582f4 --- /dev/null +++ b/.superpowers/sdd/final-review-package.md @@ -0,0 +1,1544 @@ +# Final Review Package + +## Base +4fc9271 + +## Head +6341a6bd8b5f79a919b31358ff2c0996b07b4f59 + +## Commits +6341a6b test: cover project channel workbench flow +7086b8b fix: style active inspector view +092930e style: apply workbench visual system +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates +95dce81 feat: add project channel workbench shell +6c12d4b feat: add project workspace data model +5b95668 feat: add full workbench login page + +## Stat + .superpowers/sdd/task-2-report.md | 44 ++++ + .superpowers/sdd/task-4-report.md | 67 ++++++ + .superpowers/sdd/task-5-report.md | 43 ++++ + apps/web/e2e/project-workbench.spec.ts | 31 ++- + apps/web/src/app/App.svelte | 77 +------ + apps/web/src/features/auth/ServerLogin.svelte | 63 ++++-- + apps/web/src/features/auth/ServerLogin.test.ts | 49 +++++ + .../src/features/workbench/ChannelContent.svelte | 30 +++ + .../src/features/workbench/ChannelContent.test.ts | 39 ++++ + .../src/features/workbench/ObjectInspector.svelte | 37 ++++ + .../workbench/ProjectChannelSidebar.svelte | 52 +++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 +++ + .../src/features/workbench/ProjectWorkbench.svelte | 48 ++++ + .../features/workbench/ProjectWorkbench.test.ts | 54 +++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 ++ + .../workbench/channels/AISessionsChannel.svelte | 27 +++ + .../features/workbench/channels/CronChannel.svelte | 19 ++ + .../workbench/channels/CustomLinkChannel.svelte | 16 ++ + .../workbench/channels/InboxChannel.svelte | 22 ++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 ++ + .../workbench/channels/OverviewChannel.svelte | 29 +++ + .../workbench/channels/TasksChannel.svelte | 23 ++ + apps/web/src/features/workbench/mockData.test.ts | 29 +++ + apps/web/src/features/workbench/mockData.ts | 61 +++++ + apps/web/src/features/workbench/types.ts | 93 ++++++++ + apps/web/src/index.css | 245 ++++++++++++--------- + 26 files changed, 1058 insertions(+), 201 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..48a37cf +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,44 @@ ++# Task 2 Report: Workbench Types And Project-Scoped Mock Data ++ ++## What changed ++ ++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`. ++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`. ++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`. ++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. ++ ++## Tests ++ ++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed. ++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed. ++- Web production build: `npm run build` -> successful. ++- Formatting/diff validation: `git diff --check` -> no issues. ++ ++## TDD evidence ++ ++1. Created `mockData.test.ts` before production implementation. ++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist. ++3. Added `types.ts` and `mockData.ts` using the exact task brief values. ++4. Re-ran the targeted test and observed green: 2/2 tests passed. ++5. Ran the full Web test suite and build successfully. ++ ++## Files changed ++ ++- `apps/web/src/features/workbench/types.ts` ++- `apps/web/src/features/workbench/mockData.ts` ++- `apps/web/src/features/workbench/mockData.test.ts` ++- `.superpowers/sdd/task-2-report.md` ++ ++## Self-review ++ ++- `ChannelType` contains all seven required channel literals in the specified order. ++- `ProjectWorkspace` includes all required project-scoped collections. ++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. ++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. ++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. ++- No unrelated tracked files were changed. ++ ++## Concerns ++ ++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding. ++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. +diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md +new file mode 100644 +index 0000000..b743d8d +--- /dev/null ++++ b/.superpowers/sdd/task-4-report.md +@@ -0,0 +1,67 @@ ++# Task 4 Report: Channel Content Templates And Object Inspector ++ ++## Status ++ ++Implemented and committed the Task 4 workbench channel templates and object inspector. ++ ++## What Changed ++ ++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. ++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. ++- Made all applicable records selectable through an `Inspect <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 new file mode 100644 index 0000000..9d0e02c --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,8 @@ +# SDD Progress + +Task 1: complete (commits 4fc9271..5b95668, review clean) +Task 2: complete (commits 5b95668..6c12d4b, review clean) +Task 3: complete (commits 6c12d4b..95dce81, review clean) +Task 4: complete (commits 95dce81..1a82a8b, review clean after fix) +Task 5: complete (commits 1a82a8b..7086b8b, review clean after fix) +Task 6: complete (commits 7086b8b..6341a6b, review clean) diff --git a/.superpowers/sdd/task-1-brief.md b/.superpowers/sdd/task-1-brief.md new file mode 100644 index 0000000..1597d7c --- /dev/null +++ b/.superpowers/sdd/task-1-brief.md @@ -0,0 +1,200 @@ +## Task 1: Login Page And App-Level Session + +**Files:** +- Modify: `apps/web/src/features/auth/ServerLogin.svelte` +- Modify: `apps/web/src/app/App.svelte` +- Test: `apps/web/src/features/auth/ServerLogin.test.ts` + +**Interfaces:** +- Produces: `ServerLogin` Svelte component event `login: { apiBase: string; account: string }` +- Produces: app state fields `apiBase: string`, `currentUser: { account: string } | null` +- Consumes: `localStorage['apiBase']` + +- [ ] **Step 1: Write failing login component tests** + +Create `apps/web/src/features/auth/ServerLogin.test.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ServerLogin from './ServerLogin.svelte'; + +describe('ServerLogin', () => { + it('renders server, account, and password fields', () => { + render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } }); + + expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument(); + expect(screen.getByLabelText('Email or username')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + }); + + it('normalizes server address and emits login details', async () => { + const onLogin = vi.fn(); + render(ServerLogin, { + props: { + apiBase: 'localhost:8080', + onLogin, + }, + }); + + await fireEvent.input(screen.getByLabelText('Server IP or domain'), { + target: { value: '10.0.0.12:8080' }, + }); + await fireEvent.input(screen.getByLabelText('Email or username'), { + target: { value: 'david@example.com' }, + }); + await fireEvent.input(screen.getByLabelText('Password'), { + target: { value: 'secret' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(onLogin).toHaveBeenCalledWith({ + apiBase: 'http://10.0.0.12:8080', + account: 'david@example.com', + }); + }); + + it('shows an error when account or password is missing', async () => { + render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(screen.getByText('Enter an account and password.')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run the new test to verify it fails** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/auth/ServerLogin.test.ts +``` + +Expected: FAIL because `ServerLogin` does not render account/password fields and does not expose `onLogin`. + +- [ ] **Step 3: Replace `ServerLogin.svelte` with the complete login page** + +Implement `apps/web/src/features/auth/ServerLogin.svelte`: + +```svelte +<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 new file mode 100644 index 0000000..3887012 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,46 @@ +# Task 1 Report: Login Page And App-Level Session + +## What Changed + +- Replaced the server-only form with the complete `ServerLogin` page. +- Added server address, account, and password inputs with accessible labels. +- Added server address normalization to `http://` when no protocol is supplied. +- Added account/password validation with the required error message. +- Added the `login` callback payload: `{ apiBase, account }`. +- Updated `App.svelte` to read and persist `localStorage['apiBase']`, render the login page until login, and render the required logged-in workbench placeholder afterward. + +## Tests + +- `npm test -- --run src/features/auth/ServerLogin.test.ts`: 1 test file, 3 tests passed. +- `npm test -- --run`: 3 test files, 5 tests passed. +- `npm run build`: passed. +- `git diff --check`: passed. + +## TDD Evidence + +1. Added `ServerLogin.test.ts` before changing production code. +2. Ran the focused test and observed the expected RED result: all 3 tests failed because the old component did not expose the required English fields, login button, or callback. +3. Implemented the brief's component and app-level session behavior. +4. Re-ran the focused test and observed GREEN: all 3 tests passed. +5. Ran the full Web test suite and production build successfully. + +## Files Changed + +- `apps/web/src/features/auth/ServerLogin.svelte` +- `apps/web/src/features/auth/ServerLogin.test.ts` +- `apps/web/src/app/App.svelte` + +## Self-Review + +- The implementation is limited to the files named in the task brief, plus this report file. +- No React or shadcn/ui dependency was added. +- The component callback and app state fields match the brief exactly. +- Server address persistence occurs only after a valid login submission. +- Empty account/password input is rejected without invoking the login callback. +- The logged-in placeholder is intentionally temporary and is scoped for Task 3 replacement. + +## Concerns + +- Login is frontend-only for this task: credentials are validated for presence but no backend authentication request or token session is implemented yet. +- Vite reports the existing repository warning that no Svelte config was found; tests and build still pass. +- The new login markup uses the existing global CSS controls; dedicated login-page styling is not part of the task brief. diff --git a/.superpowers/sdd/task-1-review-package.md b/.superpowers/sdd/task-1-review-package.md new file mode 100644 index 0000000..8b8d938 --- /dev/null +++ b/.superpowers/sdd/task-1-review-package.md @@ -0,0 +1,242 @@ +# Review package Task 1 + +## Commits +5b95668 feat: add full workbench login page + +## Stat + apps/web/src/app/App.svelte | 79 ++++---------------------- + apps/web/src/features/auth/ServerLogin.svelte | 63 +++++++++++++++----- + apps/web/src/features/auth/ServerLogin.test.ts | 49 ++++++++++++++++ + 3 files changed, 110 insertions(+), 81 deletions(-) + +## Diff +diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte +index a4c6389..a5d8dce 100644 +--- a/apps/web/src/app/App.svelte ++++ b/apps/web/src/app/App.svelte +@@ -1,76 +1,21 @@ + <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 new file mode 100644 index 0000000..6e03842 --- /dev/null +++ b/.superpowers/sdd/task-2-brief.md @@ -0,0 +1,250 @@ +## Task 2: Workbench Types And Project-Scoped Mock Data + +**Files:** +- Create: `apps/web/src/features/workbench/types.ts` +- Create: `apps/web/src/features/workbench/mockData.ts` +- Test: `apps/web/src/features/workbench/mockData.test.ts` + +**Interfaces:** +- Produces: `type ChannelType = 'overview' | 'inbox' | 'tasks' | 'ai_sessions' | 'notes_sources' | 'cron' | 'custom_link'` +- Produces: `getProjectWorkspace(projectID: number): ProjectWorkspace` +- Produces: `workbenchProjects: WorkbenchProject[]` + +- [ ] **Step 1: Write failing mock data tests** + +Create `apps/web/src/features/workbench/mockData.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { getProjectWorkspace, workbenchProjects } from './mockData'; + +describe('workbench mock data', () => { + it('provides dynamic projects', () => { + expect(workbenchProjects.length).toBeGreaterThanOrEqual(2); + expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) }); + }); + + it('loads project-specific channels and counts', () => { + const first = getProjectWorkspace(workbenchProjects[0].id); + const second = getProjectWorkspace(workbenchProjects[1].id); + + expect(first.project.id).not.toBe(second.project.id); + expect(first.channels.map((channel) => channel.type)).toEqual([ + 'overview', + 'inbox', + 'tasks', + 'ai_sessions', + 'notes_sources', + 'cron', + 'custom_link', + ]); + expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({ + title: expect.any(String), + url: expect.stringMatching(/^https?:\/\//), + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/mockData.test.ts +``` + +Expected: FAIL because the files do not exist. + +- [ ] **Step 3: Create `types.ts`** + +Create `apps/web/src/features/workbench/types.ts`: + +```ts +export type ChannelType = + | 'overview' + | 'inbox' + | 'tasks' + | 'ai_sessions' + | 'notes_sources' + | 'cron' + | 'custom_link'; + +export type WorkbenchProject = { + id: number; + name: string; + description: string; + initials: string; + unreadCount: number; +}; + +export type WorkbenchChannel = { + id: string; + projectID: number; + type: ChannelType; + title: string; + icon: string; + count?: number; + url?: string; + sortOrder: number; +}; + +export type InboxMessage = { + id: string; + source: string; + title: string; + summary: string; + status: 'open' | 'processed' | 'archived'; + tag: string; + time: string; +}; + +export type WorkTask = { + id: string; + title: string; + summary: string; + completed: boolean; + owner: string; + due: string; + tag: string; +}; + +export type AISessionItem = { + id: string; + title: string; + summary: string; + updatedAt: string; + references: string[]; +}; + +export type NoteSourceItem = { + id: string; + kind: 'note' | 'file' | 'link'; + title: string; + updatedAt: string; + tag: string; + source: string; +}; + +export type CronPlan = { + id: string; + title: string; + schedule: string; + nextRun: string; + enabled: boolean; + lastResult: string; + owner: string; +}; + +export type InspectorItem = { + title: string; + type: string; + description: string; + properties: Array<{ label: string; value: string }>; +}; + +export type ProjectWorkspace = { + project: WorkbenchProject; + channels: WorkbenchChannel[]; + tags: string[]; + recentSessions: AISessionItem[]; + inbox: InboxMessage[]; + tasks: WorkTask[]; + aiSessions: AISessionItem[]; + notesSources: NoteSourceItem[]; + cronPlans: CronPlan[]; +}; +``` + +- [ ] **Step 4: Create `mockData.ts`** + +Create `apps/web/src/features/workbench/mockData.ts` with two project workspaces: + +```ts +import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types'; + +export const workbenchProjects: WorkbenchProject[] = [ + { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 }, + { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 }, +]; + +function systemChannels(projectID: number): WorkbenchChannel[] { + return [ + { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 }, + { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 }, + { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 }, + { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 }, + { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 }, + { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 }, + { + id: `${projectID}-custom-roadmap`, + projectID, + type: 'custom_link', + title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard', + icon: 'link', + url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2', + sortOrder: 7, + }, + ]; +} + +const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({ + project, + channels: systemChannels(project.id), + tags: ['all', 'tag1', 'UI', 'knowledge'], + recentSessions: [ + { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] }, + { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] }, + ], + inbox: [ + { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' }, + { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' }, + ], + tasks: [ + { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' }, + { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' }, + ], + aiSessions: [ + { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] }, + { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] }, + ], + notesSources: [ + { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' }, + { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' }, + { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' }, + ], + cronPlans: [ + { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' }, + { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' }, + ], +})); + +export function getProjectWorkspace(projectID: number): ProjectWorkspace { + return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0]; +} +``` + +- [ ] **Step 5: Run test** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/mockData.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent +git add apps\web\src\features\workbench\types.ts apps\web\src\features\workbench\mockData.ts apps\web\src\features\workbench\mockData.test.ts +git commit -m "feat: add project workspace data model" +``` + +--- + diff --git a/.superpowers/sdd/task-2-review-package.md b/.superpowers/sdd/task-2-review-package.md new file mode 100644 index 0000000..0e499f8 --- /dev/null +++ b/.superpowers/sdd/task-2-review-package.md @@ -0,0 +1,264 @@ +# Review package Task 2 + +## Commits +6c12d4b feat: add project workspace data model + +## Stat + .superpowers/sdd/task-2-report.md | 44 +++++++++++ + apps/web/src/features/workbench/mockData.test.ts | 29 ++++++++ + apps/web/src/features/workbench/mockData.ts | 61 ++++++++++++++++ + apps/web/src/features/workbench/types.ts | 93 ++++++++++++++++++++++++ + 4 files changed, 227 insertions(+) + +## Diff +diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md +new file mode 100644 +index 0000000..48a37cf +--- /dev/null ++++ b/.superpowers/sdd/task-2-report.md +@@ -0,0 +1,44 @@ ++# Task 2 Report: Workbench Types And Project-Scoped Mock Data ++ ++## What changed ++ ++- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`. ++- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`. ++- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`. ++- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. ++ ++## Tests ++ ++- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed. ++- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed. ++- Web production build: `npm run build` -> successful. ++- Formatting/diff validation: `git diff --check` -> no issues. ++ ++## TDD evidence ++ ++1. Created `mockData.test.ts` before production implementation. ++2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist. ++3. Added `types.ts` and `mockData.ts` using the exact task brief values. ++4. Re-ran the targeted test and observed green: 2/2 tests passed. ++5. Ran the full Web test suite and build successfully. ++ ++## Files changed ++ ++- `apps/web/src/features/workbench/types.ts` ++- `apps/web/src/features/workbench/mockData.ts` ++- `apps/web/src/features/workbench/mockData.test.ts` ++- `.superpowers/sdd/task-2-report.md` ++ ++## Self-review ++ ++- `ChannelType` contains all seven required channel literals in the specified order. ++- `ProjectWorkspace` includes all required project-scoped collections. ++- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. ++- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. ++- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. ++- No unrelated tracked files were changed. ++ ++## Concerns ++ ++- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding. ++- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. +diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts +new file mode 100644 +index 0000000..fdbf451 +--- /dev/null ++++ b/apps/web/src/features/workbench/mockData.test.ts +@@ -0,0 +1,29 @@ ++import { describe, expect, it } from 'vitest'; ++import { getProjectWorkspace, workbenchProjects } from './mockData'; ++ ++describe('workbench mock data', () => { ++ it('provides dynamic projects', () => { ++ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2); ++ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) }); ++ }); ++ ++ it('loads project-specific channels and counts', () => { ++ const first = getProjectWorkspace(workbenchProjects[0].id); ++ const second = getProjectWorkspace(workbenchProjects[1].id); ++ ++ expect(first.project.id).not.toBe(second.project.id); ++ expect(first.channels.map((channel) => channel.type)).toEqual([ ++ 'overview', ++ 'inbox', ++ 'tasks', ++ 'ai_sessions', ++ 'notes_sources', ++ 'cron', ++ 'custom_link', ++ ]); ++ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({ ++ title: expect.any(String), ++ url: expect.stringMatching(/^https?:\/\//), ++ }); ++ }); ++}); +diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts +new file mode 100644 +index 0000000..90a25c2 +--- /dev/null ++++ b/apps/web/src/features/workbench/mockData.ts +@@ -0,0 +1,61 @@ ++import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types'; ++ ++export const workbenchProjects: WorkbenchProject[] = [ ++ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 }, ++ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 }, ++]; ++ ++function systemChannels(projectID: number): WorkbenchChannel[] { ++ return [ ++ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 }, ++ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 }, ++ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 }, ++ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 }, ++ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 }, ++ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 }, ++ { ++ id: `${projectID}-custom-roadmap`, ++ projectID, ++ type: 'custom_link', ++ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard', ++ icon: 'link', ++ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2', ++ sortOrder: 7, ++ }, ++ ]; ++} ++ ++const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({ ++ project, ++ channels: systemChannels(project.id), ++ tags: ['all', 'tag1', 'UI', 'knowledge'], ++ recentSessions: [ ++ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] }, ++ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] }, ++ ], ++ inbox: [ ++ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' }, ++ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' }, ++ ], ++ tasks: [ ++ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' }, ++ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' }, ++ ], ++ aiSessions: [ ++ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] }, ++ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] }, ++ ], ++ notesSources: [ ++ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' }, ++ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' }, ++ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' }, ++ ], ++ cronPlans: [ ++ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' }, ++ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' }, ++ ], ++})); ++ ++export function getProjectWorkspace(projectID: number): ProjectWorkspace { ++ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0]; ++} +diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts +new file mode 100644 +index 0000000..98ae0f9 +--- /dev/null ++++ b/apps/web/src/features/workbench/types.ts +@@ -0,0 +1,93 @@ ++export type ChannelType = ++ | 'overview' ++ | 'inbox' ++ | 'tasks' ++ | 'ai_sessions' ++ | 'notes_sources' ++ | 'cron' ++ | 'custom_link'; ++ ++export type WorkbenchProject = { ++ id: number; ++ name: string; ++ description: string; ++ initials: string; ++ unreadCount: number; ++}; ++ ++export type WorkbenchChannel = { ++ id: string; ++ projectID: number; ++ type: ChannelType; ++ title: string; ++ icon: string; ++ count?: number; ++ url?: string; ++ sortOrder: number; ++}; ++ ++export type InboxMessage = { ++ id: string; ++ source: string; ++ title: string; ++ summary: string; ++ status: 'open' | 'processed' | 'archived'; ++ tag: string; ++ time: string; ++}; ++ ++export type WorkTask = { ++ id: string; ++ title: string; ++ summary: string; ++ completed: boolean; ++ owner: string; ++ due: string; ++ tag: string; ++}; ++ ++export type AISessionItem = { ++ id: string; ++ title: string; ++ summary: string; ++ updatedAt: string; ++ references: string[]; ++}; ++ ++export type NoteSourceItem = { ++ id: string; ++ kind: 'note' | 'file' | 'link'; ++ title: string; ++ updatedAt: string; ++ tag: string; ++ source: string; ++}; ++ ++export type CronPlan = { ++ id: string; ++ title: string; ++ schedule: string; ++ nextRun: string; ++ enabled: boolean; ++ lastResult: string; ++ owner: string; ++}; ++ ++export type InspectorItem = { ++ title: string; ++ type: string; ++ description: string; ++ properties: Array<{ label: string; value: string }>; ++}; ++ ++export type ProjectWorkspace = { ++ project: WorkbenchProject; ++ channels: WorkbenchChannel[]; ++ tags: string[]; ++ recentSessions: AISessionItem[]; ++ inbox: InboxMessage[]; ++ tasks: WorkTask[]; ++ aiSessions: AISessionItem[]; ++ notesSources: NoteSourceItem[]; ++ cronPlans: CronPlan[]; ++}; diff --git a/.superpowers/sdd/task-3-brief.md b/.superpowers/sdd/task-3-brief.md new file mode 100644 index 0000000..93b273f --- /dev/null +++ b/.superpowers/sdd/task-3-brief.md @@ -0,0 +1,263 @@ +## Task 3: Workbench Shell, Project Rail, Channel Sidebar, And Topbar + +**Files:** +- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte` +- Create: `apps/web/src/features/workbench/ProjectRail.svelte` +- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte` +- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte` +- Modify: `apps/web/src/app/App.svelte` +- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts` + +**Interfaces:** +- Consumes: `workbenchProjects: WorkbenchProject[]` +- Consumes: `getProjectWorkspace(projectID: number): ProjectWorkspace` +- Produces: `ProjectWorkbench` prop `currentUser: { account: string }` +- Produces: selected project and selected channel rendered with accessible names. + +- [ ] **Step 1: Write failing workbench shell test** + +Create `apps/web/src/features/workbench/ProjectWorkbench.test.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ProjectWorkbench from './ProjectWorkbench.svelte'; + +describe('ProjectWorkbench', () => { + it('renders project rail, channel sidebar, topbar, and inspector', () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + expect(screen.getByLabelText('Project list')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Project channels')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument(); + expect(screen.getByLabelText('Global search')).toBeInTheDocument(); + expect(screen.getByLabelText('Object inspector')).toBeInTheDocument(); + }); + + it('refreshes channels when switching projects', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + + expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Ops Dashboard')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/ProjectWorkbench.test.ts +``` + +Expected: FAIL because workbench components do not exist. + +- [ ] **Step 3: Create shell components** + +Implement `ProjectRail.svelte`, `ProjectChannelSidebar.svelte`, and `WorkspaceTopbar.svelte` with explicit props and accessible buttons: + +```svelte +<!-- 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 new file mode 100644 index 0000000..6b0768c --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,49 @@ +# Task 3 Report: Workbench Shell, Project Rail, Channel Sidebar, And Topbar + +## Status + +Implemented the Task 3 workbench shell and integrated it with the authenticated application state. + +## What Changed + +- Added `ProjectWorkbench.svelte` to compose the workspace topbar, project rail, project channel sidebar, channel stage, and object inspector. +- Added `ProjectRail.svelte` with accessible project buttons, selected state, unread counts, dashboard, and create-project controls. +- Added `ProjectChannelSidebar.svelte` with project metadata, tags, channels, selected state, and recent sessions. +- Added `WorkspaceTopbar.svelte` with a global search input, navigation controls, and account button. +- Replaced the signed-in placeholder in `App.svelte` with `ProjectWorkbench`. +- Added `ProjectWorkbench.test.ts` to verify the accessible shell landmarks and project switching behavior. + +## TDD Evidence + +1. Created `ProjectWorkbench.test.ts` before the workbench components existed. +2. Ran `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` from `apps/web`. +3. Confirmed the expected red failure: Vite could not resolve `./ProjectWorkbench.svelte` because the component had not yet been created. +4. Implemented the shell components and application integration. +5. Re-ran the focused test and confirmed 2 passing tests. + +## Tests + +- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` - passed: 1 test file, 2 tests. +- `npm run build` - passed: Vite production build completed successfully. +- `git diff --check` - passed: no whitespace errors. + +## Files Changed + +- `apps/web/src/app/App.svelte` +- `apps/web/src/features/workbench/ProjectWorkbench.svelte` +- `apps/web/src/features/workbench/ProjectRail.svelte` +- `apps/web/src/features/workbench/ProjectChannelSidebar.svelte` +- `apps/web/src/features/workbench/WorkspaceTopbar.svelte` +- `apps/web/src/features/workbench/ProjectWorkbench.test.ts` + +## Self-Review + +- The implementation consumes `workbenchProjects` and `getProjectWorkspace` from the existing Task 2 model. +- Selected project buttons expose `aria-pressed`; the sidebar, project rail, global search, and inspector expose the required accessible names. +- Changing the project updates the workspace channels and resets the selected channel to the project's first channel. +- No channel-specific content templates or dependencies were added. +- Changes are limited to the task brief's application and workbench files; this report is the separately requested deliverable. + +## Concerns + +None. The existing Vite setup reports that no Svelte config is present, but the focused test and production build both pass. diff --git a/.superpowers/sdd/task-3-review-package.md b/.superpowers/sdd/task-3-review-package.md new file mode 100644 index 0000000..95c8c76 --- /dev/null +++ b/.superpowers/sdd/task-3-review-package.md @@ -0,0 +1,234 @@ +# Review package Task 3 + +## Commits +95dce81 feat: add project channel workbench shell + +## Stat + apps/web/src/app/App.svelte | 6 +-- + .../workbench/ProjectChannelSidebar.svelte | 52 ++++++++++++++++++++++ + apps/web/src/features/workbench/ProjectRail.svelte | 26 +++++++++++ + .../src/features/workbench/ProjectWorkbench.svelte | 41 +++++++++++++++++ + .../features/workbench/ProjectWorkbench.test.ts | 26 +++++++++++ + .../src/features/workbench/WorkspaceTopbar.svelte | 16 +++++++ + 6 files changed, 163 insertions(+), 4 deletions(-) + +## Diff +diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte +index a5d8dce..e7170ef 100644 +--- a/apps/web/src/app/App.svelte ++++ b/apps/web/src/app/App.svelte +@@ -1,21 +1,19 @@ + <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 new file mode 100644 index 0000000..6700a40 --- /dev/null +++ b/.superpowers/sdd/task-4-brief.md @@ -0,0 +1,232 @@ +## Task 4: Channel Content Templates And Object Inspector + +**Files:** +- Create: `apps/web/src/features/workbench/ChannelContent.svelte` +- Create: `apps/web/src/features/workbench/ObjectInspector.svelte` +- Create: channel components under `apps/web/src/features/workbench/channels/` +- Modify: `apps/web/src/features/workbench/ProjectWorkbench.svelte` +- Test: `apps/web/src/features/workbench/ChannelContent.test.ts` + +**Interfaces:** +- Consumes: `ProjectWorkspace` +- Consumes: selected `WorkbenchChannel` +- Produces: `onInspect(item: InspectorItem): void` +- Produces: per-channel headings and selectable records. + +- [ ] **Step 1: Write failing channel content test** + +Create `apps/web/src/features/workbench/ChannelContent.test.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData'; + +describe('ChannelContent', () => { + const workspace = getProjectWorkspace(1); + + it('renders different templates for system and custom channels', () => { + for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) { + const channel = workspace.channels.find((item) => item.type === type); + if (!channel) throw new Error(`missing ${type}`); + + render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); + } + + expect(screen.getByText('Open external channel')).toBeInTheDocument(); + }); + + it('sends selected task details to inspector', async () => { + let inspectedTitle = ''; + const channel = workspace.channels.find((item) => item.type === 'tasks'); + if (!channel) throw new Error('missing task channel'); + + render(ChannelContent, { + props: { + workspace, + channel, + onInspect: (item) => { + inspectedTitle = item.title; + }, + }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + expect(inspectedTitle).toBe('Confirm homepage information architecture'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/ChannelContent.test.ts +``` + +Expected: FAIL because `ChannelContent.svelte` does not exist. + +- [ ] **Step 3: Implement `ChannelContent.svelte` dispatcher** + +Create a dispatcher that selects the channel component by `channel.type`: + +```svelte +<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 new file mode 100644 index 0000000..e6c7290 --- /dev/null +++ b/.superpowers/sdd/task-4-review-package-v2.md @@ -0,0 +1,524 @@ +# Review package Task 4 v2 + +## Commits +1a82a8b fix: address workbench inspector review findings +3194cbe feat: add workbench channel templates + +## Stat + .superpowers/sdd/task-4-report.md | 67 ++++++++++++++++++++++ + .../src/features/workbench/ChannelContent.svelte | 30 ++++++++++ + .../src/features/workbench/ChannelContent.test.ts | 39 +++++++++++++ + .../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++ + .../src/features/workbench/ProjectWorkbench.svelte | 15 +++-- + .../features/workbench/ProjectWorkbench.test.ts | 22 ++++++- + .../workbench/channels/AISessionsChannel.svelte | 27 +++++++++ + .../features/workbench/channels/CronChannel.svelte | 19 ++++++ + .../workbench/channels/CustomLinkChannel.svelte | 16 ++++++ + .../workbench/channels/InboxChannel.svelte | 22 +++++++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 ++++++ + .../workbench/channels/OverviewChannel.svelte | 29 ++++++++++ + .../workbench/channels/TasksChannel.svelte | 23 ++++++++ + 13 files changed, 360 insertions(+), 5 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md +new file mode 100644 +index 0000000..b743d8d +--- /dev/null ++++ b/.superpowers/sdd/task-4-report.md +@@ -0,0 +1,67 @@ ++# Task 4 Report: Channel Content Templates And Object Inspector ++ ++## Status ++ ++Implemented and committed the Task 4 workbench channel templates and object inspector. ++ ++## What Changed ++ ++- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. ++- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. ++- Made all applicable records selectable through an `Inspect <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 new file mode 100644 index 0000000..dae3f6c --- /dev/null +++ b/.superpowers/sdd/task-4-review-package.md @@ -0,0 +1,398 @@ +# Review package Task 4 + +## Commits +3194cbe feat: add workbench channel templates + +## Stat + .../src/features/workbench/ChannelContent.svelte | 30 +++++++++++++++++ + .../src/features/workbench/ChannelContent.test.ts | 39 ++++++++++++++++++++++ + .../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++++++++++ + .../src/features/workbench/ProjectWorkbench.svelte | 15 ++++++--- + .../workbench/channels/AISessionsChannel.svelte | 27 +++++++++++++++ + .../features/workbench/channels/CronChannel.svelte | 19 +++++++++++ + .../workbench/channels/CustomLinkChannel.svelte | 16 +++++++++ + .../workbench/channels/InboxChannel.svelte | 22 ++++++++++++ + .../workbench/channels/NotesSourcesChannel.svelte | 19 +++++++++++ + .../workbench/channels/OverviewChannel.svelte | 29 ++++++++++++++++ + .../workbench/channels/TasksChannel.svelte | 23 +++++++++++++ + 11 files changed, 272 insertions(+), 4 deletions(-) + +## Diff +diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte +new file mode 100644 +index 0000000..9e81962 +--- /dev/null ++++ b/apps/web/src/features/workbench/ChannelContent.svelte +@@ -0,0 +1,30 @@ ++<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 new file mode 100644 index 0000000..1767ae6 --- /dev/null +++ b/.superpowers/sdd/task-5-brief.md @@ -0,0 +1,258 @@ +## Task 5: shadcn/ui-Inspired Visual System In CSS + +**Files:** +- Modify: `apps/web/src/index.css` +- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts` + +**Interfaces:** +- Consumes: class names introduced in Tasks 1-4. +- Produces: stable desktop layout, responsive mobile layout, accessible focus states. + +- [ ] **Step 1: Add a test assertion for active state semantics** + +Extend `ProjectWorkbench.test.ts` with: + +```ts +it('marks active channel with aria-pressed', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + + expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true'); +}); +``` + +- [ ] **Step 2: Run test** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/ProjectWorkbench.test.ts +``` + +Expected: PASS before visual CSS work; this protects state semantics while styling changes. + +- [ ] **Step 3: Replace `index.css` with workbench visual system** + +Implement CSS tokens and component classes: + +```css +:root { + color: #18181b; + background: #f4f4f5; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + --background: #f4f4f5; + --panel: #ffffff; + --panel-muted: #fafafa; + --border: #d4d4d8; + --text: #18181b; + --muted: #71717a; + --primary: #0f7ae5; + --primary-strong: #0969c8; + --success: #15803d; + --danger: #b91c1c; + --radius: 8px; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +button, input, textarea, select { font: inherit; } +button { cursor: pointer; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--background); +} + +.login-panel { + width: min(420px, 100%); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + padding: 24px; +} + +.server-login, +.login-heading, +.channel-page, +.object-inspector, +.channel-sidebar { + display: grid; + gap: 12px; +} + +.server-login input, +.search-box input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 10px; + background: white; +} + +.server-login button, +.primary-action { + border: 1px solid var(--primary); + border-radius: 6px; + background: var(--primary); + color: white; + padding: 9px 12px; +} + +.workbench-shell { + min-height: 100vh; + display: grid; + grid-template-rows: 64px 1fr; +} + +.workspace-topbar { + display: grid; + grid-template-columns: 160px minmax(240px, 1fr) auto; + align-items: center; + gap: 16px; + border-bottom: 1px solid var(--border); + background: var(--panel); + padding: 0 16px; +} + +.workbench-body { + min-height: 0; + display: grid; + grid-template-columns: 76px 280px minmax(0, 1fr) 320px; +} + +.project-rail, +.channel-sidebar, +.object-inspector { + border-right: 1px solid var(--border); + background: var(--panel); +} + +.project-rail { + display: grid; + align-content: start; + gap: 10px; + padding: 12px; +} + +.project-rail button { + min-width: 48px; + min-height: 48px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-muted); +} + +.project-rail button.active, +.channel-group button.active { + border-color: var(--primary); + color: var(--primary); + background: #eff6ff; +} + +.channel-sidebar, +.object-inspector, +.channel-stage { + padding: 16px; + overflow: auto; +} + +.channel-group, +.recent-sessions, +.task-list, +.record-list { + display: grid; + gap: 8px; +} + +.channel-group button, +.recent-sessions button, +.task-card, +.record-row, +.cron-row { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + padding: 10px 12px; +} + +.task-card, +.record-row, +.cron-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: start; +} + +.task-card.completed { + color: var(--muted); + background: var(--panel-muted); +} + +.inspector-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.inspector-tabs button[aria-selected='true'] { + border-color: var(--primary); + color: var(--primary); +} + +@media (max-width: 920px) { + .workspace-topbar { + grid-template-columns: 1fr; + height: auto; + padding: 12px; + } + + .workbench-shell { + grid-template-rows: auto 1fr; + } + + .workbench-body { + grid-template-columns: 1fr; + } + + .project-rail, + .channel-sidebar, + .object-inspector { + border-right: 0; + border-bottom: 1px solid var(--border); + } +} +``` + +- [ ] **Step 4: Run component tests and build** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run +npm run build +``` + +Expected: both PASS. + +- [ ] **Step 5: Commit** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent +git add apps\web\src\index.css apps\web\src\features\workbench\ProjectWorkbench.test.ts +git commit -m "style: apply workbench visual system" +``` + +--- + diff --git a/.superpowers/sdd/task-5-review-package-v2.md b/.superpowers/sdd/task-5-review-package-v2.md new file mode 100644 index 0000000..bc07d00 --- /dev/null +++ b/.superpowers/sdd/task-5-review-package-v2.md @@ -0,0 +1,401 @@ +# Review package Task 5 v2 + +## Commits +7086b8b fix: style active inspector view +092930e style: apply workbench visual system + +## Stat + .superpowers/sdd/task-5-report.md | 43 ++++ + .../features/workbench/ProjectWorkbench.test.ts | 8 + + apps/web/src/index.css | 245 ++++++++++++--------- + 3 files changed, 187 insertions(+), 109 deletions(-) + +## Diff +diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md +new file mode 100644 +index 0000000..d4fc752 +--- /dev/null ++++ b/.superpowers/sdd/task-5-report.md +@@ -0,0 +1,43 @@ ++# Task 5 Report: shadcn/ui-Inspired Visual System In CSS ++ ++## Status ++ ++Implemented and committed the Task 5 workbench visual system. ++ ++## What Changed ++ ++- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles. ++- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`. ++ ++## Tests ++ ++From `apps/web`: ++ ++- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests. ++- `npm test -- --run` passed: 6 test files, 13 tests. ++- `npm run build` passed: Vite production build completed successfully. ++ ++## TDD Evidence ++ ++The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component. ++ ++## Files Changed ++ ++- `apps/web/src/index.css` ++- `apps/web/src/features/workbench/ProjectWorkbench.test.ts` ++ ++## Self-Review ++ ++- CSS values and selectors follow the Task 5 brief verbatim. ++- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px. ++- Keyboard focus is visibly indicated for buttons and inputs. ++- No React or shadcn/ui dependencies were added. ++- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched. ++ ++## Concerns ++ ++The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below. ++ ++## Review Fix ++ ++Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics. +diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts +index 61e15b1..f2b6e72 100644 +--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts ++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts +@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => { + + it('refreshes channels when switching projects', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + + expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Ops Dashboard')).toBeInTheDocument(); + }); + ++ it('marks active channel with aria-pressed', async () => { ++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); ++ ++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); ++ ++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true'); ++ }); ++ + it('shows inspected task details in the inspector', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + + expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument(); +diff --git a/apps/web/src/index.css b/apps/web/src/index.css +index 7a1e945..160edde 100644 +--- a/apps/web/src/index.css ++++ b/apps/web/src/index.css +@@ -1,165 +1,192 @@ + :root { +- color: #202124; +- background: #f6f7f9; +- font-family: +- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; ++ color: #18181b; ++ background: #f4f4f5; ++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +-} +- +-* { +- box-sizing: border-box; +-} +- +-body { +- margin: 0; +- min-width: 320px; ++ --background: #f4f4f5; ++ --panel: #ffffff; ++ --panel-muted: #fafafa; ++ --border: #d4d4d8; ++ --text: #18181b; ++ --muted: #71717a; ++ --primary: #0f7ae5; ++ --primary-strong: #0969c8; ++ --success: #15803d; ++ --danger: #b91c1c; ++ --radius: 8px; ++} ++ ++* { box-sizing: border-box; } ++body { margin: 0; min-width: 320px; min-height: 100vh; } ++button, input, textarea, select { font: inherit; } ++button { cursor: pointer; } ++button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } ++ ++.login-page { + min-height: 100vh; +-} +- +-button, +-input, +-textarea, +-select { +- font: inherit; +-} +- +-.shell { + display: grid; +- grid-template-columns: 280px 1fr; +- min-height: 100vh; +-} +- +-.sidebar { +- border-right: 1px solid #d9dde3; +- background: #ffffff; ++ place-items: center; + padding: 24px; ++ background: var(--background); + } + +-.sidebar h1 { +- margin: 0 0 24px; +- font-size: 24px; +-} +- +-.workspace { +- display: grid; +- gap: 24px; ++.login-panel { ++ width: min(420px, 100%); ++ border: 1px solid var(--border); ++ border-radius: var(--radius); ++ background: var(--panel); + padding: 24px; + } + +-.connection-status, +-.selection-status { +- margin: 12px 0 0; +- color: #5f6673; +- font-size: 13px; +-} +- +-.server-login { ++.server-login, ++.login-heading, ++.channel-page, ++.object-inspector, ++.channel-sidebar { + display: grid; +- gap: 8px; +-} +- +-.server-login label { +- font-size: 13px; +- color: #4b5563; ++ gap: 12px; + } + +-.server-login input { ++.server-login input, ++.search-box input { + width: 100%; +- border: 1px solid #c7ccd4; ++ border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 10px; ++ background: white; + } + +-.server-login button { +- border: 1px solid #1f2937; ++.server-login button, ++.primary-action { ++ border: 1px solid var(--primary); + border-radius: 6px; +- background: #1f2937; ++ background: var(--primary); + color: white; +- padding: 9px 10px; +- cursor: pointer; ++ padding: 9px 12px; + } + +-.dashboard-grid { ++.workbench-shell { ++ min-height: 100vh; + display: grid; +- grid-template-columns: repeat(4, minmax(160px, 1fr)); +- gap: 12px; ++ grid-template-rows: 64px 1fr; + } + +-.dashboard-metric { +- border: 1px solid #d9dde3; +- border-radius: 8px; +- background: white; +- padding: 16px; ++.workspace-topbar { ++ display: grid; ++ grid-template-columns: 160px minmax(240px, 1fr) auto; ++ align-items: center; ++ gap: 16px; ++ border-bottom: 1px solid var(--border); ++ background: var(--panel); ++ padding: 0 16px; + } + +-.dashboard-metric span { +- display: block; +- color: #5f6673; +- font-size: 14px; ++.workbench-body { ++ min-height: 0; ++ display: grid; ++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px; + } + +-.dashboard-metric strong { +- display: block; +- margin-top: 8px; +- font-size: 28px; ++.project-rail, ++.channel-sidebar, ++.object-inspector { ++ border-right: 1px solid var(--border); ++ background: var(--panel); + } + +-.inbox-review { ++.project-rail { + display: grid; +- gap: 12px; ++ align-content: start; ++ gap: 10px; ++ padding: 12px; ++} ++ ++.project-rail button { ++ min-width: 48px; ++ min-height: 48px; ++ border: 1px solid var(--border); ++ border-radius: 999px; ++ background: var(--panel-muted); ++} ++ ++.project-rail button.active, ++.channel-group button.active { ++ border-color: var(--primary); ++ color: var(--primary); ++ background: #eff6ff; + } + +-.inbox-review h2 { +- margin: 0; +- font-size: 18px; ++.channel-sidebar, ++.object-inspector, ++.channel-stage { ++ padding: 16px; ++ overflow: auto; + } + +-.suggestions { ++.channel-group, ++.recent-sessions, ++.task-list, ++.record-list { + display: grid; +- gap: 10px; ++ gap: 8px; + } + +-.suggestion { ++.channel-group button, ++.recent-sessions button, ++.task-card, ++.record-row, ++.cron-row { ++ width: 100%; ++ border: 1px solid var(--border); ++ border-radius: var(--radius); ++ background: var(--panel); ++ padding: 10px 12px; ++} ++ ++.task-card, ++.record-row, ++.cron-row { + display: grid; +- grid-template-columns: 20px 1fr; +- gap: 10px; ++ grid-template-columns: auto 1fr auto; ++ gap: 12px; + align-items: start; +- border: 1px solid #d9dde3; +- border-radius: 8px; +- background: white; +- padding: 12px; + } + +-.suggestion-body { +- display: grid; +- gap: 4px; ++.task-card.completed { ++ color: var(--muted); ++ background: var(--panel-muted); + } + +-.suggestion-body small { +- color: #5f6673; ++.inspector-tabs { ++ display: grid; ++ grid-template-columns: repeat(3, 1fr); ++ gap: 4px; + } + +-.suggestions button { +- justify-self: start; +- border: 1px solid #1f2937; +- border-radius: 6px; +- background: #1f2937; +- color: white; +- padding: 9px 12px; +- cursor: pointer; ++.inspector-tabs button[aria-pressed='true'] { ++ border-color: var(--primary); ++ color: var(--primary); + } + +-@media (max-width: 760px) { +- .shell { ++@media (max-width: 920px) { ++ .workspace-topbar { + grid-template-columns: 1fr; ++ height: auto; ++ padding: 12px; + } + +- .sidebar { +- border-right: 0; +- border-bottom: 1px solid #d9dde3; ++ .workbench-shell { ++ grid-template-rows: auto 1fr; + } + +- .dashboard-grid { +- grid-template-columns: repeat(2, minmax(0, 1fr)); ++ .workbench-body { ++ grid-template-columns: 1fr; ++ } ++ ++ .project-rail, ++ .channel-sidebar, ++ .object-inspector { ++ border-right: 0; ++ border-bottom: 1px solid var(--border); + } + } diff --git a/.superpowers/sdd/task-5-review-package.md b/.superpowers/sdd/task-5-review-package.md new file mode 100644 index 0000000..78795ad --- /dev/null +++ b/.superpowers/sdd/task-5-review-package.md @@ -0,0 +1,350 @@ +# Review package Task 5 + +## Commits +092930e style: apply workbench visual system + +## Stat + .../features/workbench/ProjectWorkbench.test.ts | 8 + + apps/web/src/index.css | 245 ++++++++++++--------- + 2 files changed, 144 insertions(+), 109 deletions(-) + +## Diff +diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts +index 61e15b1..f2b6e72 100644 +--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts ++++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts +@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => { + + it('refreshes channels when switching projects', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + + expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Ops Dashboard')).toBeInTheDocument(); + }); + ++ it('marks active channel with aria-pressed', async () => { ++ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); ++ ++ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); ++ ++ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true'); ++ }); ++ + it('shows inspected task details in the inspector', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + + expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument(); +diff --git a/apps/web/src/index.css b/apps/web/src/index.css +index 7a1e945..5a9f932 100644 +--- a/apps/web/src/index.css ++++ b/apps/web/src/index.css +@@ -1,165 +1,192 @@ + :root { +- color: #202124; +- background: #f6f7f9; +- font-family: +- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; ++ color: #18181b; ++ background: #f4f4f5; ++ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; +-} +- +-* { +- box-sizing: border-box; +-} +- +-body { +- margin: 0; +- min-width: 320px; ++ --background: #f4f4f5; ++ --panel: #ffffff; ++ --panel-muted: #fafafa; ++ --border: #d4d4d8; ++ --text: #18181b; ++ --muted: #71717a; ++ --primary: #0f7ae5; ++ --primary-strong: #0969c8; ++ --success: #15803d; ++ --danger: #b91c1c; ++ --radius: 8px; ++} ++ ++* { box-sizing: border-box; } ++body { margin: 0; min-width: 320px; min-height: 100vh; } ++button, input, textarea, select { font: inherit; } ++button { cursor: pointer; } ++button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } ++ ++.login-page { + min-height: 100vh; +-} +- +-button, +-input, +-textarea, +-select { +- font: inherit; +-} +- +-.shell { + display: grid; +- grid-template-columns: 280px 1fr; +- min-height: 100vh; +-} +- +-.sidebar { +- border-right: 1px solid #d9dde3; +- background: #ffffff; ++ place-items: center; + padding: 24px; ++ background: var(--background); + } + +-.sidebar h1 { +- margin: 0 0 24px; +- font-size: 24px; +-} +- +-.workspace { +- display: grid; +- gap: 24px; ++.login-panel { ++ width: min(420px, 100%); ++ border: 1px solid var(--border); ++ border-radius: var(--radius); ++ background: var(--panel); + padding: 24px; + } + +-.connection-status, +-.selection-status { +- margin: 12px 0 0; +- color: #5f6673; +- font-size: 13px; +-} +- +-.server-login { ++.server-login, ++.login-heading, ++.channel-page, ++.object-inspector, ++.channel-sidebar { + display: grid; +- gap: 8px; +-} +- +-.server-login label { +- font-size: 13px; +- color: #4b5563; ++ gap: 12px; + } + +-.server-login input { ++.server-login input, ++.search-box input { + width: 100%; +- border: 1px solid #c7ccd4; ++ border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 10px; ++ background: white; + } + +-.server-login button { +- border: 1px solid #1f2937; ++.server-login button, ++.primary-action { ++ border: 1px solid var(--primary); + border-radius: 6px; +- background: #1f2937; ++ background: var(--primary); + color: white; +- padding: 9px 10px; +- cursor: pointer; ++ padding: 9px 12px; + } + +-.dashboard-grid { ++.workbench-shell { ++ min-height: 100vh; + display: grid; +- grid-template-columns: repeat(4, minmax(160px, 1fr)); +- gap: 12px; ++ grid-template-rows: 64px 1fr; + } + +-.dashboard-metric { +- border: 1px solid #d9dde3; +- border-radius: 8px; +- background: white; +- padding: 16px; ++.workspace-topbar { ++ display: grid; ++ grid-template-columns: 160px minmax(240px, 1fr) auto; ++ align-items: center; ++ gap: 16px; ++ border-bottom: 1px solid var(--border); ++ background: var(--panel); ++ padding: 0 16px; + } + +-.dashboard-metric span { +- display: block; +- color: #5f6673; +- font-size: 14px; ++.workbench-body { ++ min-height: 0; ++ display: grid; ++ grid-template-columns: 76px 280px minmax(0, 1fr) 320px; + } + +-.dashboard-metric strong { +- display: block; +- margin-top: 8px; +- font-size: 28px; ++.project-rail, ++.channel-sidebar, ++.object-inspector { ++ border-right: 1px solid var(--border); ++ background: var(--panel); + } + +-.inbox-review { ++.project-rail { + display: grid; +- gap: 12px; ++ align-content: start; ++ gap: 10px; ++ padding: 12px; ++} ++ ++.project-rail button { ++ min-width: 48px; ++ min-height: 48px; ++ border: 1px solid var(--border); ++ border-radius: 999px; ++ background: var(--panel-muted); ++} ++ ++.project-rail button.active, ++.channel-group button.active { ++ border-color: var(--primary); ++ color: var(--primary); ++ background: #eff6ff; + } + +-.inbox-review h2 { +- margin: 0; +- font-size: 18px; ++.channel-sidebar, ++.object-inspector, ++.channel-stage { ++ padding: 16px; ++ overflow: auto; + } + +-.suggestions { ++.channel-group, ++.recent-sessions, ++.task-list, ++.record-list { + display: grid; +- gap: 10px; ++ gap: 8px; + } + +-.suggestion { ++.channel-group button, ++.recent-sessions button, ++.task-card, ++.record-row, ++.cron-row { ++ width: 100%; ++ border: 1px solid var(--border); ++ border-radius: var(--radius); ++ background: var(--panel); ++ padding: 10px 12px; ++} ++ ++.task-card, ++.record-row, ++.cron-row { + display: grid; +- grid-template-columns: 20px 1fr; +- gap: 10px; ++ grid-template-columns: auto 1fr auto; ++ gap: 12px; + align-items: start; +- border: 1px solid #d9dde3; +- border-radius: 8px; +- background: white; +- padding: 12px; + } + +-.suggestion-body { +- display: grid; +- gap: 4px; ++.task-card.completed { ++ color: var(--muted); ++ background: var(--panel-muted); + } + +-.suggestion-body small { +- color: #5f6673; ++.inspector-tabs { ++ display: grid; ++ grid-template-columns: repeat(3, 1fr); ++ gap: 4px; + } + +-.suggestions button { +- justify-self: start; +- border: 1px solid #1f2937; +- border-radius: 6px; +- background: #1f2937; +- color: white; +- padding: 9px 12px; +- cursor: pointer; ++.inspector-tabs button[aria-selected='true'] { ++ border-color: var(--primary); ++ color: var(--primary); + } + +-@media (max-width: 760px) { +- .shell { ++@media (max-width: 920px) { ++ .workspace-topbar { + grid-template-columns: 1fr; ++ height: auto; ++ padding: 12px; + } + +- .sidebar { +- border-right: 0; +- border-bottom: 1px solid #d9dde3; ++ .workbench-shell { ++ grid-template-rows: auto 1fr; + } + +- .dashboard-grid { +- grid-template-columns: repeat(2, minmax(0, 1fr)); ++ .workbench-body { ++ grid-template-columns: 1fr; ++ } ++ ++ .project-rail, ++ .channel-sidebar, ++ .object-inspector { ++ border-right: 0; ++ border-bottom: 1px solid var(--border); + } + } diff --git a/.superpowers/sdd/task-6-brief.md b/.superpowers/sdd/task-6-brief.md new file mode 100644 index 0000000..14c9381 --- /dev/null +++ b/.superpowers/sdd/task-6-brief.md @@ -0,0 +1,106 @@ +## Task 6: Playwright Smoke Flow + +**Files:** +- Modify: `apps/web/e2e/project-workbench.spec.ts` + +**Interfaces:** +- Consumes: login form labels from Task 1. +- Consumes: project/channel labels from Tasks 2-4. +- Produces: smoke coverage for login, dynamic project switching, channel template switching, custom URL channel. + +- [ ] **Step 1: Replace Playwright smoke tests** + +Update `apps/web/e2e/project-workbench.spec.ts`: + +```ts +import { expect, test } from '@playwright/test'; + +test('login and project channel workbench flow', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByLabel('Server IP or domain')).toBeVisible(); + await page.getByLabel('Server IP or domain').fill('localhost:8080'); + await page.getByLabel('Email or username').fill('david@example.com'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByLabel('Project list')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible(); + + await page.getByRole('button', { name: 'Work Plan 8' }).click(); + await expect(page.getByText('Tasks')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible(); + + await page.getByRole('button', { name: 'Project A2' }).click(); + await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByText('Ops Dashboard')).toBeVisible(); + + await page.getByRole('button', { name: 'Ops Dashboard' }).click(); + await expect(page.getByText('Open external channel')).toBeVisible(); +}); +``` + +- [ ] **Step 2: Run Playwright test** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npx playwright test +``` + +Expected: PASS. + +- [ ] **Step 3: Run full web verification** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run +npm run build +npx playwright test +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent +git add apps\web\e2e\project-workbench.spec.ts +git commit -m "test: cover project channel workbench flow" +``` + +--- + +## Self-Review + +Spec coverage: + +- Login page with server IP/domain, account, password, status, and errors: Task 1. +- Dynamic project rail and project-scoped channel refresh: Tasks 2 and 3. +- System channels with different page templates: Task 4. +- Message flow similar to email: Task 4. +- Work plan Todo card style with complete/incomplete state: Task 4. +- AI sessions with session list and details: Task 4. +- Notes and sources file/attachment management style: Task 4. +- Cron plan/reminder management without autonomous Agent execution: Task 4 and Global Constraints. +- Custom channel with title, icon, URL: Tasks 2 and 4. +- Right inspector with Discussion, Properties, More: Task 4. +- shadcn/ui-inspired visual system without React/shadcn dependency: Task 5. +- Playwright workflow coverage: Task 6. + +Completeness scan: + +- The plan avoids unresolved markers, deferred-work wording, and vague validation instructions. +- Each code-writing step includes concrete file content or concrete implementation shape with exact paths. + +Type consistency: + +- `ChannelType`, `WorkbenchChannel`, `ProjectWorkspace`, and `InspectorItem` are introduced in Task 2 and consumed consistently in Tasks 3 and 4. +- `onInspect(item: InspectorItem): void` is the only inspector selection interface. +- `onLogin({ apiBase, account })` is the only login completion interface. diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 0000000..7b178dc --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -0,0 +1,47 @@ +# Task 6 Report: Playwright Smoke Flow + +## Status + +Completed and committed the scoped Playwright smoke-flow update. + +## What Changed + +Replaced the legacy workbench shell and inbox smoke tests with one end-to-end test, `login and project channel workbench flow`. The flow covers: + +- Login using the server address, account, and password fields. +- Initial Project A1 selection and visibility of the Message Flow 36 channel. +- Switching to the Work Plan 8 channel and confirming its task template and inspector action. +- Switching projects to Project A2 and confirming its active state and channel refresh. +- Opening the Ops Dashboard custom URL channel and confirming its external-channel state. + +## Tests + +All verification commands completed successfully from `apps/web`: + +| Command | Result | +| --- | --- | +| `npx playwright test` | 1 passed | +| `npm test -- --run` | 6 files, 13 tests passed | +| `npm run build` | Passed | +| `npx playwright test` (final verification) | 1 passed | + +## Files Changed + +- `apps/web/e2e/project-workbench.spec.ts`: replaced the previous smoke cases with the required login, project-switching, channel-template, and custom URL-channel flow. +- `.superpowers/sdd/task-6-report.md`: this implementation report. + +## Self-Review + +- Used the exact labels, button names, values, and assertions supplied in the Task 6 brief. +- Confirmed the smoke flow checks both the default active project and the dynamically refreshed Project A2 channel list. +- Confirmed channel coverage reaches both the Work Plan template and the custom external URL channel state. +- Kept application code and dependencies unchanged; no React or shadcn/ui dependency was added. +- Confirmed `git diff --check` exits successfully and the code diff is scoped to the requested Playwright spec. + +## Concerns + +None. Vite reports that no Svelte config file is present and uses its default configuration during tests and build; this is an existing informational message and did not affect verification. + +## Commit + +`test: cover project channel workbench flow` diff --git a/.superpowers/sdd/task-6-review-package.md b/.superpowers/sdd/task-6-review-package.md new file mode 100644 index 0000000..6826a24 --- /dev/null +++ b/.superpowers/sdd/task-6-review-package.md @@ -0,0 +1,52 @@ +# Review package Task 6 + +## Commits +6341a6b test: cover project channel workbench flow + +## Stat + apps/web/e2e/project-workbench.spec.ts | 31 ++++++++++++++++++++----------- + 1 file changed, 20 insertions(+), 11 deletions(-) + +## Diff +diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts +index 8a2358a..b1fee8b 100644 +--- a/apps/web/e2e/project-workbench.spec.ts ++++ b/apps/web/e2e/project-workbench.spec.ts +@@ -1,17 +1,26 @@ + import { expect, test } from '@playwright/test'; + +-test('project workbench shell renders', async ({ page }) => { ++test('login and project channel workbench flow', async ({ page }) => { + await page.goto('/'); +- await expect(page.getByText('项目工作台')).toBeVisible(); +- await expect(page.getByLabel('服务器登录')).toBeVisible(); +- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible(); +- await expect(page.getByLabel('项目总览')).toBeVisible(); +-}); + +-test('inbox suggestions require explicit confirmation', async ({ page }) => { +- await page.goto('/'); +- await page.getByLabel('选择 跟进报价').check(); +- await page.getByRole('button', { name: '创建选中项' }).click(); ++ await expect(page.getByLabel('Server IP or domain')).toBeVisible(); ++ await page.getByLabel('Server IP or domain').fill('localhost:8080'); ++ await page.getByLabel('Email or username').fill('david@example.com'); ++ await page.getByLabel('Password').fill('secret'); ++ await page.getByRole('button', { name: 'Log in' }).click(); ++ ++ await expect(page.getByLabel('Project list')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Work Plan 8' }).click(); ++ await expect(page.getByText('Tasks')).toBeVisible(); ++ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible(); ++ ++ await page.getByRole('button', { name: 'Project A2' }).click(); ++ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); ++ await expect(page.getByText('Ops Dashboard')).toBeVisible(); + +- await expect(page.getByText('已选择 1 项')).toBeVisible(); ++ await page.getByRole('button', { name: 'Ops Dashboard' }).click(); ++ await expect(page.getByText('Open external channel')).toBeVisible(); + }); diff --git a/design-qa-comparison.png b/design-qa-comparison.png new file mode 100644 index 0000000..897c9fd Binary files /dev/null and b/design-qa-comparison.png differ diff --git a/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png b/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png new file mode 100644 index 0000000..ca87df3 Binary files /dev/null and b/docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png differ