superpowers

This commit is contained in:
2026-07-20 08:02:20 +08:00
parent 0f2cbcb9ee
commit 3669e23085
25 changed files with 13451 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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"
```
---

View File

@@ -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.

View File

@@ -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();
+ });
+});

View File

@@ -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"
```
---

View File

@@ -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[];
+};

View File

@@ -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"
```
---

View File

@@ -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.

View File

@@ -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">&#9881;</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">&#8592;</button>
+ <button aria-label="Forward">&#8594;</button>
+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
+ </div>
+</header>

View File

@@ -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"
```
---

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
```
---

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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`

View File

@@ -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();
});

BIN
design-qa-comparison.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB