diff --git a/docs/superpowers/plans/2026-07-19-discord-style-project-workbench-ui.md b/docs/superpowers/plans/2026-07-19-discord-style-project-workbench-ui.md new file mode 100644 index 0000000..1c554b6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-discord-style-project-workbench-ui.md @@ -0,0 +1,1373 @@ +# Discord Style Project Workbench UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the confirmed shadcn/ui-inspired Svelte workbench UI with a full login page, dynamic project rail, project-scoped channel sidebar, channel-specific content pages, and a right-side object inspector. + +**Architecture:** Keep the implementation frontend-focused under `apps/web/src`. `App.svelte` owns authentication state and switches between login and workbench. `ProjectWorkbench.svelte` owns selected project, selected channel, selected object, and passes focused data into small channel components. Mock project/channel data lives in a local typed data module so later backend API wiring can replace it without reshaping every component. + +**Tech Stack:** Svelte 5, TypeScript, Vite, Vitest, Testing Library for Svelte, Playwright, local CSS. Visual style follows root `design.md`; do not add React or shadcn/ui runtime dependencies. + +## Global Constraints + +- Frontend only uses Svelte; do not add React dependencies or React components. +- UI can visually reference shadcn/ui, but must not add shadcn/ui as a dependency. +- Web app is desktop-first while remaining usable on mobile browsers. +- Feature code lives under `apps/web/src/features`. +- Login page must allow users to enter server IP address or domain. +- Browser-style workspace state is separate from editor drafts and AI input drafts. +- MVP uses Markdown for note editing. +- Project tags remain project-scoped; do not introduce global tags. +- AI may not create official objects without user confirmation. +- Cron plan UI is plan/reminder management only; do not implement autonomous Agent execution. +- Custom channels are URL shortcuts with title, icon, URL, and sort order. +- Web verification runs from `apps/web`: `npm test -- --run`, `npm run build`, and Playwright when main workflow changes. + +--- + +## File Structure + +Create or modify these files: + +- Modify: `apps/web/src/app/App.svelte` + Owns app-level login/workbench switching, persisted API base URL, and mock login session. +- Modify: `apps/web/src/features/auth/ServerLogin.svelte` + Becomes a complete login page with server address, account, password, submit, and error/connection state. +- Create: `apps/web/src/features/workbench/types.ts` + Defines `WorkbenchProject`, `WorkbenchChannel`, `ChannelType`, channel object types, inspector model, and mock item interfaces. +- Create: `apps/web/src/features/workbench/mockData.ts` + Provides typed dynamic projects, project-specific system channels, custom URL channels, tags, recent sessions, inbox messages, tasks, notes/sources, cron plans, and AI sessions. +- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte` + Main logged-in shell with topbar, project rail, channel sidebar, main channel content, and inspector. +- Create: `apps/web/src/features/workbench/ProjectRail.svelte` + Dynamic project list with active project state and create-project action. +- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte` + Project-scoped channel list, tags, custom channels, recent sessions, and counts. +- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte` + Logo, global search, navigation actions, account action. +- Create: `apps/web/src/features/workbench/ChannelContent.svelte` + Dispatches selected channel to the matching channel component. +- Create: `apps/web/src/features/workbench/ObjectInspector.svelte` + Right panel with Discussion, Properties, and More tabs. +- Create: `apps/web/src/features/workbench/channels/OverviewChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/InboxChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/TasksChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/AISessionsChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/CronChannel.svelte` +- Create: `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte` +- Create: `apps/web/src/features/workbench/ProjectWorkbench.test.ts` +- Modify: `apps/web/e2e/project-workbench.spec.ts` +- Modify: `apps/web/src/index.css` + +--- + +## 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 + + +
+
+ + + + +
+
+``` + +- [ ] **Step 4: Update `App.svelte` to switch from login to workbench** + +Temporarily render a simple logged-in placeholder; the full workbench arrives in Task 3. + +```svelte + + +{#if currentUser} +
+

SenlinAI Workbench

+

Signed in as {currentUser.account}

+
+{:else} + +{/if} +``` + +- [ ] **Step 5: Run tests** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/auth/ServerLogin.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent +git add apps\web\src\features\auth\ServerLogin.svelte apps\web\src\features\auth\ServerLogin.test.ts apps\web\src\app\App.svelte +git commit -m "feat: add full workbench login page" +``` + +--- + +## 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" +``` + +--- + +## 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 + + + + +``` + +```svelte + + + + +``` + +```svelte + + + +
+
SenlinAI
+ +
+ + + +
+
+``` + +- [ ] **Step 4: Create `ProjectWorkbench.svelte` and wire into `App.svelte`** + +Create `ProjectWorkbench.svelte`: + +```svelte + + +
+ +
+ + +
+

{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}

+
+ +
+
+``` + +Modify `App.svelte` to import and render `ProjectWorkbench` when logged in: + +```svelte + + +{#if currentUser} + +{:else} + +{/if} +``` + +- [ ] **Step 5: Run test** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/ProjectWorkbench.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent +git add apps\web\src\app\App.svelte apps\web\src\features\workbench +git commit -m "feat: add project channel workbench shell" +``` + +--- + +## Task 4: Channel Content Templates And Object Inspector + +**Files:** +- Create: `apps/web/src/features/workbench/ChannelContent.svelte` +- Create: `apps/web/src/features/workbench/ObjectInspector.svelte` +- Create: channel components under `apps/web/src/features/workbench/channels/` +- Modify: `apps/web/src/features/workbench/ProjectWorkbench.svelte` +- Test: `apps/web/src/features/workbench/ChannelContent.test.ts` + +**Interfaces:** +- Consumes: `ProjectWorkspace` +- Consumes: selected `WorkbenchChannel` +- Produces: `onInspect(item: InspectorItem): void` +- Produces: per-channel headings and selectable records. + +- [ ] **Step 1: Write failing channel content test** + +Create `apps/web/src/features/workbench/ChannelContent.test.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData'; + +describe('ChannelContent', () => { + const workspace = getProjectWorkspace(1); + + it('renders different templates for system and custom channels', () => { + for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) { + const channel = workspace.channels.find((item) => item.type === type); + if (!channel) throw new Error(`missing ${type}`); + + render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); + } + + expect(screen.getByText('Open external channel')).toBeInTheDocument(); + }); + + it('sends selected task details to inspector', async () => { + let inspectedTitle = ''; + const channel = workspace.channels.find((item) => item.type === 'tasks'); + if (!channel) throw new Error('missing task channel'); + + render(ChannelContent, { + props: { + workspace, + channel, + onInspect: (item) => { + inspectedTitle = item.title; + }, + }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + expect(inspectedTitle).toBe('Confirm homepage information architecture'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```powershell +Set-Location D:\work\senlinai\agent\apps\web +npm test -- --run src/features/workbench/ChannelContent.test.ts +``` + +Expected: FAIL because `ChannelContent.svelte` does not exist. + +- [ ] **Step 3: Implement `ChannelContent.svelte` dispatcher** + +Create a dispatcher that selects the channel component by `channel.type`: + +```svelte + + +{#if channel.type === 'overview'} + +{:else if channel.type === 'inbox'} + +{:else if channel.type === 'tasks'} + +{:else if channel.type === 'ai_sessions'} + +{:else if channel.type === 'notes_sources'} + +{:else if channel.type === 'cron'} + +{:else} + +{/if} +``` + +- [ ] **Step 4: Implement channel components** + +Each channel component should render its own page template and call `onInspect` for selectable records. Example for `TasksChannel.svelte`: + +```svelte + + +
+
+

Work Plan

+

Tasks

+
+
+ {#each tasks as task} +
+ +
+

{task.title}

+

{task.summary}

+ {task.owner} · {task.due} · #{task.tag} +
+ +
+ {/each} +
+
+``` + +Implement the remaining channel components with the same pattern: + +- `OverviewChannel.svelte`: metrics for inbox, tasks, AI sessions, notes/sources, cron plans. +- `InboxChannel.svelte`: email-like message list with source, title, summary, status, tag, time. +- `AISessionsChannel.svelte`: session list plus selected conversation summary area. +- `NotesSourcesChannel.svelte`: file-manager-like list of note/file/link records. +- `CronChannel.svelte`: scheduled task rows with enabled state, schedule, next run, last result. +- `CustomLinkChannel.svelte`: external URL page with `Open external channel` link and copy-style button. + +- [ ] **Step 5: Implement `ObjectInspector.svelte` and wire it into `ProjectWorkbench.svelte`** + +Create `ObjectInspector.svelte`: + +```svelte + + + +``` + +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" +``` + +--- + +## 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" +``` + +--- + +## 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.