superpowers
This commit is contained in:
1825
.superpowers/sdd/final-review-package-v2.md
Normal file
1825
.superpowers/sdd/final-review-package-v2.md
Normal file
File diff suppressed because it is too large
Load Diff
1952
.superpowers/sdd/final-review-package-v3.md
Normal file
1952
.superpowers/sdd/final-review-package-v3.md
Normal file
File diff suppressed because it is too large
Load Diff
2162
.superpowers/sdd/final-review-package-v4.md
Normal file
2162
.superpowers/sdd/final-review-package-v4.md
Normal file
File diff suppressed because it is too large
Load Diff
2044
.superpowers/sdd/final-review-package-v5.md
Normal file
2044
.superpowers/sdd/final-review-package-v5.md
Normal file
File diff suppressed because it is too large
Load Diff
1544
.superpowers/sdd/final-review-package.md
Normal file
1544
.superpowers/sdd/final-review-package.md
Normal file
File diff suppressed because it is too large
Load Diff
8
.superpowers/sdd/progress.md
Normal file
8
.superpowers/sdd/progress.md
Normal 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)
|
||||
200
.superpowers/sdd/task-1-brief.md
Normal file
200
.superpowers/sdd/task-1-brief.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
46
.superpowers/sdd/task-1-report.md
Normal file
46
.superpowers/sdd/task-1-report.md
Normal 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.
|
||||
242
.superpowers/sdd/task-1-review-package.md
Normal file
242
.superpowers/sdd/task-1-review-package.md
Normal 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();
|
||||
+ });
|
||||
+});
|
||||
250
.superpowers/sdd/task-2-brief.md
Normal file
250
.superpowers/sdd/task-2-brief.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
264
.superpowers/sdd/task-2-review-package.md
Normal file
264
.superpowers/sdd/task-2-review-package.md
Normal 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[];
|
||||
+};
|
||||
263
.superpowers/sdd/task-3-brief.md
Normal file
263
.superpowers/sdd/task-3-brief.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
49
.superpowers/sdd/task-3-report.md
Normal file
49
.superpowers/sdd/task-3-report.md
Normal 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.
|
||||
234
.superpowers/sdd/task-3-review-package.md
Normal file
234
.superpowers/sdd/task-3-review-package.md
Normal 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">⚙</button>
|
||||
+ </header>
|
||||
+
|
||||
+ <section class="tag-row" aria-label="Project tags">
|
||||
+ {#each tags as tag}
|
||||
+ <button type="button">#{tag}</button>
|
||||
+ {/each}
|
||||
+ </section>
|
||||
+
|
||||
+ <section class="channel-group">
|
||||
+ {#each channels as channel}
|
||||
+ <button
|
||||
+ class:active={channel.id === selectedChannelID}
|
||||
+ aria-pressed={channel.id === selectedChannelID}
|
||||
+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
||||
+ on:click={() => onSelectChannel(channel.id)}
|
||||
+ >
|
||||
+ <span>{channel.title}</span>
|
||||
+ {#if channel.count !== undefined}
|
||||
+ <small>{channel.count}</small>
|
||||
+ {/if}
|
||||
+ </button>
|
||||
+ {/each}
|
||||
+ </section>
|
||||
+
|
||||
+ <section class="recent-sessions" aria-label="Recent sessions">
|
||||
+ <h3>Recent sessions</h3>
|
||||
+ {#each recentSessions as session}
|
||||
+ <button type="button">
|
||||
+ <span>{session.title}</span>
|
||||
+ <small>{session.updatedAt}</small>
|
||||
+ </button>
|
||||
+ {/each}
|
||||
+ </section>
|
||||
+</aside>
|
||||
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
|
||||
new file mode 100644
|
||||
index 0000000..d1be341
|
||||
--- /dev/null
|
||||
+++ b/apps/web/src/features/workbench/ProjectRail.svelte
|
||||
@@ -0,0 +1,26 @@
|
||||
+<script lang="ts">
|
||||
+ import type { WorkbenchProject } from './types';
|
||||
+
|
||||
+ export let projects: WorkbenchProject[];
|
||||
+ export let selectedProjectID: number;
|
||||
+ export let onSelectProject: (projectID: number) => void;
|
||||
+</script>
|
||||
+
|
||||
+<nav class="project-rail" aria-label="Project list">
|
||||
+ <button class="rail-logo" aria-label="Dashboard">SA</button>
|
||||
+ {#each projects as project}
|
||||
+ <button
|
||||
+ class:active={project.id === selectedProjectID}
|
||||
+ aria-pressed={project.id === selectedProjectID}
|
||||
+ aria-label={project.name}
|
||||
+ title={project.name}
|
||||
+ on:click={() => onSelectProject(project.id)}
|
||||
+ >
|
||||
+ <span>{project.initials}</span>
|
||||
+ {#if project.unreadCount}
|
||||
+ <small>{project.unreadCount}</small>
|
||||
+ {/if}
|
||||
+ </button>
|
||||
+ {/each}
|
||||
+ <button class="add-project" aria-label="Create project">+</button>
|
||||
+</nav>
|
||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
||||
new file mode 100644
|
||||
index 0000000..4ab9ffd
|
||||
--- /dev/null
|
||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
||||
@@ -0,0 +1,41 @@
|
||||
+<script lang="ts">
|
||||
+ import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
+ import ProjectRail from './ProjectRail.svelte';
|
||||
+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
+ import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
+
|
||||
+ export let currentUser: { account: string };
|
||||
+
|
||||
+ let selectedProjectID = workbenchProjects[0].id;
|
||||
+ $: workspace = getProjectWorkspace(selectedProjectID);
|
||||
+ $: selectedChannelID = workspace.channels[0].id;
|
||||
+
|
||||
+ function selectProject(projectID: number) {
|
||||
+ selectedProjectID = projectID;
|
||||
+ }
|
||||
+
|
||||
+ function selectChannel(channelID: string) {
|
||||
+ selectedChannelID = channelID;
|
||||
+ }
|
||||
+</script>
|
||||
+
|
||||
+<main class="workbench-shell">
|
||||
+ <WorkspaceTopbar account={currentUser.account} />
|
||||
+ <div class="workbench-body">
|
||||
+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
||||
+ <ProjectChannelSidebar
|
||||
+ project={workspace.project}
|
||||
+ channels={workspace.channels}
|
||||
+ {selectedChannelID}
|
||||
+ tags={workspace.tags}
|
||||
+ recentSessions={workspace.recentSessions}
|
||||
+ onSelectChannel={selectChannel}
|
||||
+ />
|
||||
+ <section class="channel-stage" aria-label="Channel content">
|
||||
+ <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
||||
+ </section>
|
||||
+ <aside class="object-inspector" aria-label="Object inspector">
|
||||
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
||||
+ </aside>
|
||||
+ </div>
|
||||
+</main>
|
||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..cf251b3
|
||||
--- /dev/null
|
||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
||||
@@ -0,0 +1,26 @@
|
||||
+import '@testing-library/jest-dom/vitest';
|
||||
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
+import { describe, expect, it } from 'vitest';
|
||||
+import ProjectWorkbench from './ProjectWorkbench.svelte';
|
||||
+
|
||||
+describe('ProjectWorkbench', () => {
|
||||
+ it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
+
|
||||
+ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||||
+ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
+ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||||
+ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
+ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
||||
+ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
+ });
|
||||
+
|
||||
+ it('refreshes channels when switching projects', async () => {
|
||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
+
|
||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
+
|
||||
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
||||
+ });
|
||||
+});
|
||||
diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
||||
new file mode 100644
|
||||
index 0000000..c828fcf
|
||||
--- /dev/null
|
||||
+++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
||||
@@ -0,0 +1,16 @@
|
||||
+<script lang="ts">
|
||||
+ export let account: string;
|
||||
+</script>
|
||||
+
|
||||
+<header class="workspace-topbar">
|
||||
+ <div class="brand-mark">SenlinAI</div>
|
||||
+ <label class="search-box">
|
||||
+ <span>Search</span>
|
||||
+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
||||
+ </label>
|
||||
+ <div class="topbar-actions">
|
||||
+ <button aria-label="Back">←</button>
|
||||
+ <button aria-label="Forward">→</button>
|
||||
+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
||||
+ </div>
|
||||
+</header>
|
||||
232
.superpowers/sdd/task-4-brief.md
Normal file
232
.superpowers/sdd/task-4-brief.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
524
.superpowers/sdd/task-4-review-package-v2.md
Normal file
524
.superpowers/sdd/task-4-review-package-v2.md
Normal 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>
|
||||
398
.superpowers/sdd/task-4-review-package.md
Normal file
398
.superpowers/sdd/task-4-review-package.md
Normal 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>
|
||||
258
.superpowers/sdd/task-5-brief.md
Normal file
258
.superpowers/sdd/task-5-brief.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
401
.superpowers/sdd/task-5-review-package-v2.md
Normal file
401
.superpowers/sdd/task-5-review-package-v2.md
Normal 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);
|
||||
}
|
||||
}
|
||||
350
.superpowers/sdd/task-5-review-package.md
Normal file
350
.superpowers/sdd/task-5-review-package.md
Normal 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);
|
||||
}
|
||||
}
|
||||
106
.superpowers/sdd/task-6-brief.md
Normal file
106
.superpowers/sdd/task-6-brief.md
Normal 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.
|
||||
47
.superpowers/sdd/task-6-report.md
Normal file
47
.superpowers/sdd/task-6-report.md
Normal 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`
|
||||
52
.superpowers/sdd/task-6-review-package.md
Normal file
52
.superpowers/sdd/task-6-review-package.md
Normal 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
BIN
design-qa-comparison.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png
Normal file
BIN
docs/call_g6DhOvUFyNza9HnLRRBIwk5l.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Reference in New Issue
Block a user