chore: align release docs and repository hygiene
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
# SDD Progress
|
|
||||||
|
|
||||||
Task 1: complete (commits 4fc9271..5b95668, review clean)
|
|
||||||
Task 2: complete (commits 5b95668..6c12d4b, review clean)
|
|
||||||
Task 3: complete (commits 6c12d4b..95dce81, review clean)
|
|
||||||
Task 4: complete (commits 95dce81..1a82a8b, review clean after fix)
|
|
||||||
Task 5: complete (commits 1a82a8b..7086b8b, review clean after fix)
|
|
||||||
Task 6: complete (commits 7086b8b..6341a6b, review clean)
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
## Task 1: Login Page And App-Level Session
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/web/src/features/auth/ServerLogin.svelte`
|
|
||||||
- Modify: `apps/web/src/app/App.svelte`
|
|
||||||
- Test: `apps/web/src/features/auth/ServerLogin.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `ServerLogin` Svelte component event `login: { apiBase: string; account: string }`
|
|
||||||
- Produces: app state fields `apiBase: string`, `currentUser: { account: string } | null`
|
|
||||||
- Consumes: `localStorage['apiBase']`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing login component tests**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/auth/ServerLogin.test.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import ServerLogin from './ServerLogin.svelte';
|
|
||||||
|
|
||||||
describe('ServerLogin', () => {
|
|
||||||
it('renders server, account, and password fields', () => {
|
|
||||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
|
||||||
|
|
||||||
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes server address and emits login details', async () => {
|
|
||||||
const onLogin = vi.fn();
|
|
||||||
render(ServerLogin, {
|
|
||||||
props: {
|
|
||||||
apiBase: 'localhost:8080',
|
|
||||||
onLogin,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
|
|
||||||
target: { value: '10.0.0.12:8080' },
|
|
||||||
});
|
|
||||||
await fireEvent.input(screen.getByLabelText('Email or username'), {
|
|
||||||
target: { value: 'david@example.com' },
|
|
||||||
});
|
|
||||||
await fireEvent.input(screen.getByLabelText('Password'), {
|
|
||||||
target: { value: 'secret' },
|
|
||||||
});
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
|
||||||
|
|
||||||
expect(onLogin).toHaveBeenCalledWith({
|
|
||||||
apiBase: 'http://10.0.0.12:8080',
|
|
||||||
account: 'david@example.com',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an error when account or password is missing', async () => {
|
|
||||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
|
||||||
|
|
||||||
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the new test to verify it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/auth/ServerLogin.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because `ServerLogin` does not render account/password fields and does not expose `onLogin`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace `ServerLogin.svelte` with the complete login page**
|
|
||||||
|
|
||||||
Implement `apps/web/src/features/auth/ServerLogin.svelte`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
export let apiBase: string;
|
|
||||||
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
|
|
||||||
|
|
||||||
let server = apiBase;
|
|
||||||
let account = '';
|
|
||||||
let password = '';
|
|
||||||
let error = '';
|
|
||||||
|
|
||||||
function normalizeServer(value: string) {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return `http://${trimmed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitLogin() {
|
|
||||||
error = '';
|
|
||||||
if (!account.trim() || !password.trim()) {
|
|
||||||
error = 'Enter an account and password.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizeServer(server);
|
|
||||||
server = normalized;
|
|
||||||
onLogin({ apiBase: normalized, account: account.trim() });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="login-page">
|
|
||||||
<section class="login-panel" aria-labelledby="login-title">
|
|
||||||
<div class="login-brand" aria-hidden="true">SA</div>
|
|
||||||
<div class="login-heading">
|
|
||||||
<p>Private workbench</p>
|
|
||||||
<h1 id="login-title">SenlinAI Workbench</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
|
|
||||||
<label for="server-address">Server IP or domain</label>
|
|
||||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
|
||||||
|
|
||||||
<label for="login-account">Email or username</label>
|
|
||||||
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
|
|
||||||
|
|
||||||
<label for="login-password">Password</label>
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
bind:value={password}
|
|
||||||
autocomplete="current-password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="form-error" aria-live="polite">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button type="submit">Log in</button>
|
|
||||||
<p class="login-note">The server address is remembered on this device.</p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `App.svelte` to switch from login to workbench**
|
|
||||||
|
|
||||||
Temporarily render a simple logged-in placeholder; the full workbench arrives in Task 3.
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
|
||||||
|
|
||||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
|
||||||
let currentUser: { account: string } | null = null;
|
|
||||||
|
|
||||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
|
||||||
apiBase = detail.apiBase;
|
|
||||||
localStorage.setItem('apiBase', apiBase);
|
|
||||||
currentUser = { account: detail.account };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if currentUser}
|
|
||||||
<main class="workbench-placeholder" aria-label="Project workbench">
|
|
||||||
<h1>SenlinAI Workbench</h1>
|
|
||||||
<p>Signed in as {currentUser.account}</p>
|
|
||||||
</main>
|
|
||||||
{:else}
|
|
||||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
|
||||||
{/if}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/auth/ServerLogin.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\src\features\auth\ServerLogin.svelte apps\web\src\features\auth\ServerLogin.test.ts apps\web\src\app\App.svelte
|
|
||||||
git commit -m "feat: add full workbench login page"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Task 1 Report: Login Page And App-Level Session
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
- Replaced the server-only form with the complete `ServerLogin` page.
|
|
||||||
- Added server address, account, and password inputs with accessible labels.
|
|
||||||
- Added server address normalization to `http://` when no protocol is supplied.
|
|
||||||
- Added account/password validation with the required error message.
|
|
||||||
- Added the `login` callback payload: `{ apiBase, account }`.
|
|
||||||
- Updated `App.svelte` to read and persist `localStorage['apiBase']`, render the login page until login, and render the required logged-in workbench placeholder afterward.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `npm test -- --run src/features/auth/ServerLogin.test.ts`: 1 test file, 3 tests passed.
|
|
||||||
- `npm test -- --run`: 3 test files, 5 tests passed.
|
|
||||||
- `npm run build`: passed.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
|
|
||||||
## TDD Evidence
|
|
||||||
|
|
||||||
1. Added `ServerLogin.test.ts` before changing production code.
|
|
||||||
2. Ran the focused test and observed the expected RED result: all 3 tests failed because the old component did not expose the required English fields, login button, or callback.
|
|
||||||
3. Implemented the brief's component and app-level session behavior.
|
|
||||||
4. Re-ran the focused test and observed GREEN: all 3 tests passed.
|
|
||||||
5. Ran the full Web test suite and production build successfully.
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- `apps/web/src/features/auth/ServerLogin.svelte`
|
|
||||||
- `apps/web/src/features/auth/ServerLogin.test.ts`
|
|
||||||
- `apps/web/src/app/App.svelte`
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- The implementation is limited to the files named in the task brief, plus this report file.
|
|
||||||
- No React or shadcn/ui dependency was added.
|
|
||||||
- The component callback and app state fields match the brief exactly.
|
|
||||||
- Server address persistence occurs only after a valid login submission.
|
|
||||||
- Empty account/password input is rejected without invoking the login callback.
|
|
||||||
- The logged-in placeholder is intentionally temporary and is scoped for Task 3 replacement.
|
|
||||||
|
|
||||||
## Concerns
|
|
||||||
|
|
||||||
- Login is frontend-only for this task: credentials are validated for presence but no backend authentication request or token session is implemented yet.
|
|
||||||
- Vite reports the existing repository warning that no Svelte config was found; tests and build still pass.
|
|
||||||
- The new login markup uses the existing global CSS controls; dedicated login-page styling is not part of the task brief.
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Review package Task 1
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
5b95668 feat: add full workbench login page
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
apps/web/src/app/App.svelte | 79 ++++----------------------
|
|
||||||
apps/web/src/features/auth/ServerLogin.svelte | 63 +++++++++++++++-----
|
|
||||||
apps/web/src/features/auth/ServerLogin.test.ts | 49 ++++++++++++++++
|
|
||||||
3 files changed, 110 insertions(+), 81 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
|
|
||||||
index a4c6389..a5d8dce 100644
|
|
||||||
--- a/apps/web/src/app/App.svelte
|
|
||||||
+++ b/apps/web/src/app/App.svelte
|
|
||||||
@@ -1,76 +1,21 @@
|
|
||||||
<script lang="ts">
|
|
||||||
- import { onMount } from 'svelte';
|
|
||||||
- import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
|
|
||||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
|
||||||
- import SuggestionList from '../features/inbox/SuggestionList.svelte';
|
|
||||||
- import type { Suggestion } from '../features/inbox/types';
|
|
||||||
- import { createApi, type ProjectDashboardSummary } from '../lib/api';
|
|
||||||
-
|
|
||||||
- const emptyDashboard: ProjectDashboardSummary = {
|
|
||||||
- project_id: 1,
|
|
||||||
- pending_inbox_count: 0,
|
|
||||||
- open_task_count: 0,
|
|
||||||
- recent_note_count: 0,
|
|
||||||
- recent_session_count: 0,
|
|
||||||
- };
|
|
||||||
|
|
||||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
|
||||||
- let dashboard = emptyDashboard;
|
|
||||||
- let connectionStatus = '未连接';
|
|
||||||
- let selectedSuggestionCount = 0;
|
|
||||||
-
|
|
||||||
- const sampleSuggestions: Suggestion[] = [
|
|
||||||
- {
|
|
||||||
- kind: 'task',
|
|
||||||
- title: '跟进报价',
|
|
||||||
- body: '从项目 inbox 确认后创建任务,并保留来源记录。',
|
|
||||||
- },
|
|
||||||
- {
|
|
||||||
- kind: 'note',
|
|
||||||
- title: '客户背景',
|
|
||||||
- body: '把对话中的背景信息沉淀成项目笔记。',
|
|
||||||
- },
|
|
||||||
- ];
|
|
||||||
+ let currentUser: { account: string } | null = null;
|
|
||||||
|
|
||||||
- onMount(() => {
|
|
||||||
- void loadDashboard();
|
|
||||||
- });
|
|
||||||
-
|
|
||||||
- function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
|
||||||
- apiBase = event.detail.apiBase;
|
|
||||||
+ function handleLogin(detail: { apiBase: string; account: string }) {
|
|
||||||
+ apiBase = detail.apiBase;
|
|
||||||
localStorage.setItem('apiBase', apiBase);
|
|
||||||
- void loadDashboard();
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- async function loadDashboard() {
|
|
||||||
- connectionStatus = '连接中';
|
|
||||||
- try {
|
|
||||||
- dashboard = await createApi(apiBase).getProjectDashboard(1);
|
|
||||||
- connectionStatus = '已连接';
|
|
||||||
- } catch {
|
|
||||||
- dashboard = emptyDashboard;
|
|
||||||
- connectionStatus = '无法连接服务器';
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- function handleConfirm(selected: Suggestion[]) {
|
|
||||||
- selectedSuggestionCount = selected.length;
|
|
||||||
+ currentUser = { account: detail.account };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
-<main class="shell">
|
|
||||||
- <aside class="sidebar">
|
|
||||||
- <h1>项目工作台</h1>
|
|
||||||
- <ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
|
||||||
- <p class="connection-status" aria-live="polite">{connectionStatus}</p>
|
|
||||||
- </aside>
|
|
||||||
-
|
|
||||||
- <section class="workspace">
|
|
||||||
- <ProjectDashboard summary={dashboard} />
|
|
||||||
- <section class="inbox-review" aria-label="项目 inbox">
|
|
||||||
- <h2>AI 整理建议</h2>
|
|
||||||
- <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
|
|
||||||
- <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
|
|
||||||
- </section>
|
|
||||||
- </section>
|
|
||||||
-</main>
|
|
||||||
+{#if currentUser}
|
|
||||||
+ <main class="workbench-placeholder" aria-label="Project workbench">
|
|
||||||
+ <h1>SenlinAI Workbench</h1>
|
|
||||||
+ <p>Signed in as {currentUser.account}</p>
|
|
||||||
+ </main>
|
|
||||||
+{:else}
|
|
||||||
+ <ServerLogin {apiBase} onLogin={handleLogin} />
|
|
||||||
+{/if}
|
|
||||||
diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
|
|
||||||
index 8e80261..8e16cb9 100644
|
|
||||||
--- a/apps/web/src/features/auth/ServerLogin.svelte
|
|
||||||
+++ b/apps/web/src/features/auth/ServerLogin.svelte
|
|
||||||
@@ -1,28 +1,63 @@
|
|
||||||
<script lang="ts">
|
|
||||||
- import { createEventDispatcher } from 'svelte';
|
|
||||||
-
|
|
||||||
export let apiBase: string;
|
|
||||||
+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
|
|
||||||
|
|
||||||
- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
|
|
||||||
let server = apiBase;
|
|
||||||
-
|
|
||||||
- function saveServer() {
|
|
||||||
- const normalized = normalizeServer(server);
|
|
||||||
- server = normalized;
|
|
||||||
- dispatch('serverChange', { apiBase: normalized });
|
|
||||||
- }
|
|
||||||
+ let account = '';
|
|
||||||
+ let password = '';
|
|
||||||
+ let error = '';
|
|
||||||
|
|
||||||
function normalizeServer(value: string) {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return `http://${trimmed}`;
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ function submitLogin() {
|
|
||||||
+ error = '';
|
|
||||||
+ if (!account.trim() || !password.trim()) {
|
|
||||||
+ error = 'Enter an account and password.';
|
|
||||||
+ return;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ const normalized = normalizeServer(server);
|
|
||||||
+ server = normalized;
|
|
||||||
+ onLogin({ apiBase: normalized, account: account.trim() });
|
|
||||||
+ }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
-<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
|
|
||||||
- <label for="server-address">服务器 IP 或域名</label>
|
|
||||||
- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
|
||||||
- <button type="submit">保存服务器</button>
|
|
||||||
-</form>
|
|
||||||
+<main class="login-page">
|
|
||||||
+ <section class="login-panel" aria-labelledby="login-title">
|
|
||||||
+ <div class="login-brand" aria-hidden="true">SA</div>
|
|
||||||
+ <div class="login-heading">
|
|
||||||
+ <p>Private workbench</p>
|
|
||||||
+ <h1 id="login-title">SenlinAI Workbench</h1>
|
|
||||||
+ </div>
|
|
||||||
+
|
|
||||||
+ <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
|
|
||||||
+ <label for="server-address">Server IP or domain</label>
|
|
||||||
+ <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
|
||||||
+
|
|
||||||
+ <label for="login-account">Email or username</label>
|
|
||||||
+ <input id="login-account" name="account" bind:value={account} autocomplete="username" />
|
|
||||||
+
|
|
||||||
+ <label for="login-password">Password</label>
|
|
||||||
+ <input
|
|
||||||
+ id="login-password"
|
|
||||||
+ name="password"
|
|
||||||
+ type="password"
|
|
||||||
+ bind:value={password}
|
|
||||||
+ autocomplete="current-password"
|
|
||||||
+ />
|
|
||||||
+
|
|
||||||
+ {#if error}
|
|
||||||
+ <p class="form-error" aria-live="polite">{error}</p>
|
|
||||||
+ {/if}
|
|
||||||
+
|
|
||||||
+ <button type="submit">Log in</button>
|
|
||||||
+ <p class="login-note">The server address is remembered on this device.</p>
|
|
||||||
+ </form>
|
|
||||||
+ </section>
|
|
||||||
+</main>
|
|
||||||
diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..7298b4b
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/auth/ServerLogin.test.ts
|
|
||||||
@@ -0,0 +1,49 @@
|
|
||||||
+import '@testing-library/jest-dom/vitest';
|
|
||||||
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
+import { describe, expect, it, vi } from 'vitest';
|
|
||||||
+import ServerLogin from './ServerLogin.svelte';
|
|
||||||
+
|
|
||||||
+describe('ServerLogin', () => {
|
|
||||||
+ it('renders server, account, and password fields', () => {
|
|
||||||
+ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
|
||||||
+
|
|
||||||
+ expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('normalizes server address and emits login details', async () => {
|
|
||||||
+ const onLogin = vi.fn();
|
|
||||||
+ render(ServerLogin, {
|
|
||||||
+ props: {
|
|
||||||
+ apiBase: 'localhost:8080',
|
|
||||||
+ onLogin,
|
|
||||||
+ },
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
|
|
||||||
+ target: { value: '10.0.0.12:8080' },
|
|
||||||
+ });
|
|
||||||
+ await fireEvent.input(screen.getByLabelText('Email or username'), {
|
|
||||||
+ target: { value: 'david@example.com' },
|
|
||||||
+ });
|
|
||||||
+ await fireEvent.input(screen.getByLabelText('Password'), {
|
|
||||||
+ target: { value: 'secret' },
|
|
||||||
+ });
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
|
||||||
+
|
|
||||||
+ expect(onLogin).toHaveBeenCalledWith({
|
|
||||||
+ apiBase: 'http://10.0.0.12:8080',
|
|
||||||
+ account: 'david@example.com',
|
|
||||||
+ });
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('shows an error when account or password is missing', async () => {
|
|
||||||
+ render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
|
||||||
+
|
|
||||||
+ expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+});
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
## Task 2: Workbench Types And Project-Scoped Mock Data
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `apps/web/src/features/workbench/types.ts`
|
|
||||||
- Create: `apps/web/src/features/workbench/mockData.ts`
|
|
||||||
- Test: `apps/web/src/features/workbench/mockData.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `type ChannelType = 'overview' | 'inbox' | 'tasks' | 'ai_sessions' | 'notes_sources' | 'cron' | 'custom_link'`
|
|
||||||
- Produces: `getProjectWorkspace(projectID: number): ProjectWorkspace`
|
|
||||||
- Produces: `workbenchProjects: WorkbenchProject[]`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing mock data tests**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/workbench/mockData.test.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
|
|
||||||
describe('workbench mock data', () => {
|
|
||||||
it('provides dynamic projects', () => {
|
|
||||||
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
|
|
||||||
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads project-specific channels and counts', () => {
|
|
||||||
const first = getProjectWorkspace(workbenchProjects[0].id);
|
|
||||||
const second = getProjectWorkspace(workbenchProjects[1].id);
|
|
||||||
|
|
||||||
expect(first.project.id).not.toBe(second.project.id);
|
|
||||||
expect(first.channels.map((channel) => channel.type)).toEqual([
|
|
||||||
'overview',
|
|
||||||
'inbox',
|
|
||||||
'tasks',
|
|
||||||
'ai_sessions',
|
|
||||||
'notes_sources',
|
|
||||||
'cron',
|
|
||||||
'custom_link',
|
|
||||||
]);
|
|
||||||
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
|
|
||||||
title: expect.any(String),
|
|
||||||
url: expect.stringMatching(/^https?:\/\//),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/mockData.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because the files do not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create `types.ts`**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/workbench/types.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type ChannelType =
|
|
||||||
| 'overview'
|
|
||||||
| 'inbox'
|
|
||||||
| 'tasks'
|
|
||||||
| 'ai_sessions'
|
|
||||||
| 'notes_sources'
|
|
||||||
| 'cron'
|
|
||||||
| 'custom_link';
|
|
||||||
|
|
||||||
export type WorkbenchProject = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
initials: string;
|
|
||||||
unreadCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkbenchChannel = {
|
|
||||||
id: string;
|
|
||||||
projectID: number;
|
|
||||||
type: ChannelType;
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
count?: number;
|
|
||||||
url?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InboxMessage = {
|
|
||||||
id: string;
|
|
||||||
source: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
status: 'open' | 'processed' | 'archived';
|
|
||||||
tag: string;
|
|
||||||
time: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkTask = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
completed: boolean;
|
|
||||||
owner: string;
|
|
||||||
due: string;
|
|
||||||
tag: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AISessionItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
updatedAt: string;
|
|
||||||
references: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NoteSourceItem = {
|
|
||||||
id: string;
|
|
||||||
kind: 'note' | 'file' | 'link';
|
|
||||||
title: string;
|
|
||||||
updatedAt: string;
|
|
||||||
tag: string;
|
|
||||||
source: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CronPlan = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
schedule: string;
|
|
||||||
nextRun: string;
|
|
||||||
enabled: boolean;
|
|
||||||
lastResult: string;
|
|
||||||
owner: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InspectorItem = {
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
properties: Array<{ label: string; value: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProjectWorkspace = {
|
|
||||||
project: WorkbenchProject;
|
|
||||||
channels: WorkbenchChannel[];
|
|
||||||
tags: string[];
|
|
||||||
recentSessions: AISessionItem[];
|
|
||||||
inbox: InboxMessage[];
|
|
||||||
tasks: WorkTask[];
|
|
||||||
aiSessions: AISessionItem[];
|
|
||||||
notesSources: NoteSourceItem[];
|
|
||||||
cronPlans: CronPlan[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `mockData.ts`**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/workbench/mockData.ts` with two project workspaces:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
|
|
||||||
|
|
||||||
export const workbenchProjects: WorkbenchProject[] = [
|
|
||||||
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
|
|
||||||
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function systemChannels(projectID: number): WorkbenchChannel[] {
|
|
||||||
return [
|
|
||||||
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
|
|
||||||
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
|
|
||||||
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
|
|
||||||
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
|
|
||||||
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
|
|
||||||
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
|
|
||||||
{
|
|
||||||
id: `${projectID}-custom-roadmap`,
|
|
||||||
projectID,
|
|
||||||
type: 'custom_link',
|
|
||||||
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
|
|
||||||
icon: 'link',
|
|
||||||
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
|
|
||||||
sortOrder: 7,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
|
|
||||||
project,
|
|
||||||
channels: systemChannels(project.id),
|
|
||||||
tags: ['all', 'tag1', 'UI', 'knowledge'],
|
|
||||||
recentSessions: [
|
|
||||||
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
|
|
||||||
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
|
|
||||||
],
|
|
||||||
inbox: [
|
|
||||||
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
|
|
||||||
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
|
|
||||||
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
|
|
||||||
],
|
|
||||||
aiSessions: [
|
|
||||||
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
|
|
||||||
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
|
|
||||||
],
|
|
||||||
notesSources: [
|
|
||||||
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
|
|
||||||
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
|
|
||||||
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
|
|
||||||
],
|
|
||||||
cronPlans: [
|
|
||||||
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
|
|
||||||
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
|
|
||||||
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run test**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/mockData.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\src\features\workbench\types.ts apps\web\src\features\workbench\mockData.ts apps\web\src\features\workbench\mockData.test.ts
|
|
||||||
git commit -m "feat: add project workspace data model"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
# Review package Task 2
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
6c12d4b feat: add project workspace data model
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
.superpowers/sdd/task-2-report.md | 44 +++++++++++
|
|
||||||
apps/web/src/features/workbench/mockData.test.ts | 29 ++++++++
|
|
||||||
apps/web/src/features/workbench/mockData.ts | 61 ++++++++++++++++
|
|
||||||
apps/web/src/features/workbench/types.ts | 93 ++++++++++++++++++++++++
|
|
||||||
4 files changed, 227 insertions(+)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..48a37cf
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/.superpowers/sdd/task-2-report.md
|
|
||||||
@@ -0,0 +1,44 @@
|
|
||||||
+# Task 2 Report: Workbench Types And Project-Scoped Mock Data
|
|
||||||
+
|
|
||||||
+## What changed
|
|
||||||
+
|
|
||||||
+- Added the typed workbench domain model in `apps/web/src/features/workbench/types.ts`.
|
|
||||||
+- Added two project records and project-scoped workspace mock data in `apps/web/src/features/workbench/mockData.ts`.
|
|
||||||
+- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in `apps/web/src/features/workbench/mockData.test.ts`.
|
|
||||||
+- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed.
|
|
||||||
+
|
|
||||||
+## Tests
|
|
||||||
+
|
|
||||||
+- Targeted test: `npm test -- --run src/features/workbench/mockData.test.ts` -> 1 file passed, 2 tests passed.
|
|
||||||
+- Full Web test suite: `npm test -- --run` -> 4 files passed, 7 tests passed.
|
|
||||||
+- Web production build: `npm run build` -> successful.
|
|
||||||
+- Formatting/diff validation: `git diff --check` -> no issues.
|
|
||||||
+
|
|
||||||
+## TDD evidence
|
|
||||||
+
|
|
||||||
+1. Created `mockData.test.ts` before production implementation.
|
|
||||||
+2. Ran the targeted test and observed the expected red result: Vitest could not resolve `./mockData` because the implementation files did not exist.
|
|
||||||
+3. Added `types.ts` and `mockData.ts` using the exact task brief values.
|
|
||||||
+4. Re-ran the targeted test and observed green: 2/2 tests passed.
|
|
||||||
+5. Ran the full Web test suite and build successfully.
|
|
||||||
+
|
|
||||||
+## Files changed
|
|
||||||
+
|
|
||||||
+- `apps/web/src/features/workbench/types.ts`
|
|
||||||
+- `apps/web/src/features/workbench/mockData.ts`
|
|
||||||
+- `apps/web/src/features/workbench/mockData.test.ts`
|
|
||||||
+- `.superpowers/sdd/task-2-report.md`
|
|
||||||
+
|
|
||||||
+## Self-review
|
|
||||||
+
|
|
||||||
+- `ChannelType` contains all seven required channel literals in the specified order.
|
|
||||||
+- `ProjectWorkspace` includes all required project-scoped collections.
|
|
||||||
+- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation.
|
|
||||||
+- `getProjectWorkspace` returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs.
|
|
||||||
+- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format.
|
|
||||||
+- No unrelated tracked files were changed.
|
|
||||||
+
|
|
||||||
+## Concerns
|
|
||||||
+
|
|
||||||
+- Vitest and Vite report the pre-existing warning that no Svelte config was found in `apps/web`; it did not prevent tests or the build from succeeding.
|
|
||||||
+- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task.
|
|
||||||
diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..fdbf451
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/mockData.test.ts
|
|
||||||
@@ -0,0 +1,29 @@
|
|
||||||
+import { describe, expect, it } from 'vitest';
|
|
||||||
+import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
+
|
|
||||||
+describe('workbench mock data', () => {
|
|
||||||
+ it('provides dynamic projects', () => {
|
|
||||||
+ expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
|
|
||||||
+ expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('loads project-specific channels and counts', () => {
|
|
||||||
+ const first = getProjectWorkspace(workbenchProjects[0].id);
|
|
||||||
+ const second = getProjectWorkspace(workbenchProjects[1].id);
|
|
||||||
+
|
|
||||||
+ expect(first.project.id).not.toBe(second.project.id);
|
|
||||||
+ expect(first.channels.map((channel) => channel.type)).toEqual([
|
|
||||||
+ 'overview',
|
|
||||||
+ 'inbox',
|
|
||||||
+ 'tasks',
|
|
||||||
+ 'ai_sessions',
|
|
||||||
+ 'notes_sources',
|
|
||||||
+ 'cron',
|
|
||||||
+ 'custom_link',
|
|
||||||
+ ]);
|
|
||||||
+ expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
|
|
||||||
+ title: expect.any(String),
|
|
||||||
+ url: expect.stringMatching(/^https?:\/\//),
|
|
||||||
+ });
|
|
||||||
+ });
|
|
||||||
+});
|
|
||||||
diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..90a25c2
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/mockData.ts
|
|
||||||
@@ -0,0 +1,61 @@
|
|
||||||
+import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
|
|
||||||
+
|
|
||||||
+export const workbenchProjects: WorkbenchProject[] = [
|
|
||||||
+ { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
|
|
||||||
+ { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
|
|
||||||
+];
|
|
||||||
+
|
|
||||||
+function systemChannels(projectID: number): WorkbenchChannel[] {
|
|
||||||
+ return [
|
|
||||||
+ { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
|
|
||||||
+ { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
|
|
||||||
+ { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
|
|
||||||
+ { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
|
|
||||||
+ { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
|
|
||||||
+ { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
|
|
||||||
+ {
|
|
||||||
+ id: `${projectID}-custom-roadmap`,
|
|
||||||
+ projectID,
|
|
||||||
+ type: 'custom_link',
|
|
||||||
+ title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
|
|
||||||
+ icon: 'link',
|
|
||||||
+ url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
|
|
||||||
+ sortOrder: 7,
|
|
||||||
+ },
|
|
||||||
+ ];
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
|
|
||||||
+ project,
|
|
||||||
+ channels: systemChannels(project.id),
|
|
||||||
+ tags: ['all', 'tag1', 'UI', 'knowledge'],
|
|
||||||
+ recentSessions: [
|
|
||||||
+ { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
|
|
||||||
+ { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
|
|
||||||
+ ],
|
|
||||||
+ inbox: [
|
|
||||||
+ { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
|
|
||||||
+ { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
|
|
||||||
+ ],
|
|
||||||
+ tasks: [
|
|
||||||
+ { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
|
|
||||||
+ { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
|
|
||||||
+ ],
|
|
||||||
+ aiSessions: [
|
|
||||||
+ { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
|
|
||||||
+ { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
|
|
||||||
+ ],
|
|
||||||
+ notesSources: [
|
|
||||||
+ { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
|
|
||||||
+ { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
|
|
||||||
+ { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
|
|
||||||
+ ],
|
|
||||||
+ cronPlans: [
|
|
||||||
+ { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
|
|
||||||
+ { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
|
|
||||||
+ ],
|
|
||||||
+}));
|
|
||||||
+
|
|
||||||
+export function getProjectWorkspace(projectID: number): ProjectWorkspace {
|
|
||||||
+ return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
|
|
||||||
+}
|
|
||||||
diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..98ae0f9
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/types.ts
|
|
||||||
@@ -0,0 +1,93 @@
|
|
||||||
+export type ChannelType =
|
|
||||||
+ | 'overview'
|
|
||||||
+ | 'inbox'
|
|
||||||
+ | 'tasks'
|
|
||||||
+ | 'ai_sessions'
|
|
||||||
+ | 'notes_sources'
|
|
||||||
+ | 'cron'
|
|
||||||
+ | 'custom_link';
|
|
||||||
+
|
|
||||||
+export type WorkbenchProject = {
|
|
||||||
+ id: number;
|
|
||||||
+ name: string;
|
|
||||||
+ description: string;
|
|
||||||
+ initials: string;
|
|
||||||
+ unreadCount: number;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type WorkbenchChannel = {
|
|
||||||
+ id: string;
|
|
||||||
+ projectID: number;
|
|
||||||
+ type: ChannelType;
|
|
||||||
+ title: string;
|
|
||||||
+ icon: string;
|
|
||||||
+ count?: number;
|
|
||||||
+ url?: string;
|
|
||||||
+ sortOrder: number;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type InboxMessage = {
|
|
||||||
+ id: string;
|
|
||||||
+ source: string;
|
|
||||||
+ title: string;
|
|
||||||
+ summary: string;
|
|
||||||
+ status: 'open' | 'processed' | 'archived';
|
|
||||||
+ tag: string;
|
|
||||||
+ time: string;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type WorkTask = {
|
|
||||||
+ id: string;
|
|
||||||
+ title: string;
|
|
||||||
+ summary: string;
|
|
||||||
+ completed: boolean;
|
|
||||||
+ owner: string;
|
|
||||||
+ due: string;
|
|
||||||
+ tag: string;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type AISessionItem = {
|
|
||||||
+ id: string;
|
|
||||||
+ title: string;
|
|
||||||
+ summary: string;
|
|
||||||
+ updatedAt: string;
|
|
||||||
+ references: string[];
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type NoteSourceItem = {
|
|
||||||
+ id: string;
|
|
||||||
+ kind: 'note' | 'file' | 'link';
|
|
||||||
+ title: string;
|
|
||||||
+ updatedAt: string;
|
|
||||||
+ tag: string;
|
|
||||||
+ source: string;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type CronPlan = {
|
|
||||||
+ id: string;
|
|
||||||
+ title: string;
|
|
||||||
+ schedule: string;
|
|
||||||
+ nextRun: string;
|
|
||||||
+ enabled: boolean;
|
|
||||||
+ lastResult: string;
|
|
||||||
+ owner: string;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type InspectorItem = {
|
|
||||||
+ title: string;
|
|
||||||
+ type: string;
|
|
||||||
+ description: string;
|
|
||||||
+ properties: Array<{ label: string; value: string }>;
|
|
||||||
+};
|
|
||||||
+
|
|
||||||
+export type ProjectWorkspace = {
|
|
||||||
+ project: WorkbenchProject;
|
|
||||||
+ channels: WorkbenchChannel[];
|
|
||||||
+ tags: string[];
|
|
||||||
+ recentSessions: AISessionItem[];
|
|
||||||
+ inbox: InboxMessage[];
|
|
||||||
+ tasks: WorkTask[];
|
|
||||||
+ aiSessions: AISessionItem[];
|
|
||||||
+ notesSources: NoteSourceItem[];
|
|
||||||
+ cronPlans: CronPlan[];
|
|
||||||
+};
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
## Task 3: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
|
||||||
- Create: `apps/web/src/features/workbench/ProjectRail.svelte`
|
|
||||||
- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
|
|
||||||
- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
|
|
||||||
- Modify: `apps/web/src/app/App.svelte`
|
|
||||||
- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `workbenchProjects: WorkbenchProject[]`
|
|
||||||
- Consumes: `getProjectWorkspace(projectID: number): ProjectWorkspace`
|
|
||||||
- Produces: `ProjectWorkbench` prop `currentUser: { account: string }`
|
|
||||||
- Produces: selected project and selected channel rendered with accessible names.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing workbench shell test**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/workbench/ProjectWorkbench.test.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
|
||||||
|
|
||||||
describe('ProjectWorkbench', () => {
|
|
||||||
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes channels when switching projects', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because workbench components do not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create shell components**
|
|
||||||
|
|
||||||
Implement `ProjectRail.svelte`, `ProjectChannelSidebar.svelte`, and `WorkspaceTopbar.svelte` with explicit props and accessible buttons:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<!-- apps/web/src/features/workbench/ProjectRail.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { WorkbenchProject } from './types';
|
|
||||||
export let projects: WorkbenchProject[];
|
|
||||||
export let selectedProjectID: number;
|
|
||||||
export let onSelectProject: (projectID: number) => void;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="project-rail" aria-label="Project list">
|
|
||||||
<button class="rail-logo" aria-label="Dashboard">SA</button>
|
|
||||||
{#each projects as project}
|
|
||||||
<button
|
|
||||||
class:active={project.id === selectedProjectID}
|
|
||||||
aria-pressed={project.id === selectedProjectID}
|
|
||||||
aria-label={project.name}
|
|
||||||
title={project.name}
|
|
||||||
on:click={() => onSelectProject(project.id)}
|
|
||||||
>
|
|
||||||
<span>{project.initials}</span>
|
|
||||||
{#if project.unreadCount}
|
|
||||||
<small>{project.unreadCount}</small>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<button class="add-project" aria-label="Create project">+</button>
|
|
||||||
</nav>
|
|
||||||
```
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<!-- apps/web/src/features/workbench/ProjectChannelSidebar.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
|
||||||
export let project: WorkbenchProject;
|
|
||||||
export let channels: WorkbenchChannel[];
|
|
||||||
export let selectedChannelID: string;
|
|
||||||
export let tags: string[];
|
|
||||||
export let recentSessions: AISessionItem[];
|
|
||||||
export let onSelectChannel: (channelID: string) => void;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="channel-sidebar" aria-label="Project channels">
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<p>Current project</p>
|
|
||||||
<h2>{project.name}</h2>
|
|
||||||
</div>
|
|
||||||
<button aria-label="Project settings">鈿?/button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="tag-row" aria-label="Project tags">
|
|
||||||
{#each tags as tag}
|
|
||||||
<button type="button">#{tag}</button>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="channel-group">
|
|
||||||
{#each channels as channel}
|
|
||||||
<button
|
|
||||||
class:active={channel.id === selectedChannelID}
|
|
||||||
aria-pressed={channel.id === selectedChannelID}
|
|
||||||
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
|
||||||
on:click={() => onSelectChannel(channel.id)}
|
|
||||||
>
|
|
||||||
<span>{channel.title}</span>
|
|
||||||
{#if channel.count !== undefined}
|
|
||||||
<small>{channel.count}</small>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="recent-sessions" aria-label="Recent sessions">
|
|
||||||
<h3>Recent sessions</h3>
|
|
||||||
{#each recentSessions as session}
|
|
||||||
<button type="button">
|
|
||||||
<span>{session.title}</span>
|
|
||||||
<small>{session.updatedAt}</small>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
```
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<!-- apps/web/src/features/workbench/WorkspaceTopbar.svelte -->
|
|
||||||
<script lang="ts">
|
|
||||||
export let account: string;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="workspace-topbar">
|
|
||||||
<div class="brand-mark">SenlinAI</div>
|
|
||||||
<label class="search-box">
|
|
||||||
<span>Search</span>
|
|
||||||
<input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
|
||||||
</label>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<button aria-label="Back">鈫?/button>
|
|
||||||
<button aria-label="Forward">鈫?/button>
|
|
||||||
<button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create `ProjectWorkbench.svelte` and wire into `App.svelte`**
|
|
||||||
|
|
||||||
Create `ProjectWorkbench.svelte`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
import ProjectRail from './ProjectRail.svelte';
|
|
||||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
|
||||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
|
||||||
|
|
||||||
export let currentUser: { account: string };
|
|
||||||
|
|
||||||
let selectedProjectID = workbenchProjects[0].id;
|
|
||||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
|
||||||
$: selectedChannelID = workspace.channels[0].id;
|
|
||||||
|
|
||||||
function selectProject(projectID: number) {
|
|
||||||
selectedProjectID = projectID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectChannel(channelID: string) {
|
|
||||||
selectedChannelID = channelID;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="workbench-shell">
|
|
||||||
<WorkspaceTopbar account={currentUser.account} />
|
|
||||||
<div class="workbench-body">
|
|
||||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
|
||||||
<ProjectChannelSidebar
|
|
||||||
project={workspace.project}
|
|
||||||
channels={workspace.channels}
|
|
||||||
{selectedChannelID}
|
|
||||||
tags={workspace.tags}
|
|
||||||
recentSessions={workspace.recentSessions}
|
|
||||||
onSelectChannel={selectChannel}
|
|
||||||
/>
|
|
||||||
<section class="channel-stage" aria-label="Channel content">
|
|
||||||
<h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
|
||||||
</section>
|
|
||||||
<aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
```
|
|
||||||
|
|
||||||
Modify `App.svelte` to import and render `ProjectWorkbench` when logged in:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
|
||||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
|
||||||
|
|
||||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
|
||||||
let currentUser: { account: string } | null = null;
|
|
||||||
|
|
||||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
|
||||||
apiBase = detail.apiBase;
|
|
||||||
localStorage.setItem('apiBase', apiBase);
|
|
||||||
currentUser = { account: detail.account };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if currentUser}
|
|
||||||
<ProjectWorkbench {currentUser} />
|
|
||||||
{:else}
|
|
||||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
|
||||||
{/if}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run test**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\src\app\App.svelte apps\web\src\features\workbench
|
|
||||||
git commit -m "feat: add project channel workbench shell"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Task 3 Report: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implemented the Task 3 workbench shell and integrated it with the authenticated application state.
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
- Added `ProjectWorkbench.svelte` to compose the workspace topbar, project rail, project channel sidebar, channel stage, and object inspector.
|
|
||||||
- Added `ProjectRail.svelte` with accessible project buttons, selected state, unread counts, dashboard, and create-project controls.
|
|
||||||
- Added `ProjectChannelSidebar.svelte` with project metadata, tags, channels, selected state, and recent sessions.
|
|
||||||
- Added `WorkspaceTopbar.svelte` with a global search input, navigation controls, and account button.
|
|
||||||
- Replaced the signed-in placeholder in `App.svelte` with `ProjectWorkbench`.
|
|
||||||
- Added `ProjectWorkbench.test.ts` to verify the accessible shell landmarks and project switching behavior.
|
|
||||||
|
|
||||||
## TDD Evidence
|
|
||||||
|
|
||||||
1. Created `ProjectWorkbench.test.ts` before the workbench components existed.
|
|
||||||
2. Ran `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` from `apps/web`.
|
|
||||||
3. Confirmed the expected red failure: Vite could not resolve `./ProjectWorkbench.svelte` because the component had not yet been created.
|
|
||||||
4. Implemented the shell components and application integration.
|
|
||||||
5. Re-ran the focused test and confirmed 2 passing tests.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` - passed: 1 test file, 2 tests.
|
|
||||||
- `npm run build` - passed: Vite production build completed successfully.
|
|
||||||
- `git diff --check` - passed: no whitespace errors.
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- `apps/web/src/app/App.svelte`
|
|
||||||
- `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
|
||||||
- `apps/web/src/features/workbench/ProjectRail.svelte`
|
|
||||||
- `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
|
|
||||||
- `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
|
|
||||||
- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- The implementation consumes `workbenchProjects` and `getProjectWorkspace` from the existing Task 2 model.
|
|
||||||
- Selected project buttons expose `aria-pressed`; the sidebar, project rail, global search, and inspector expose the required accessible names.
|
|
||||||
- Changing the project updates the workspace channels and resets the selected channel to the project's first channel.
|
|
||||||
- No channel-specific content templates or dependencies were added.
|
|
||||||
- Changes are limited to the task brief's application and workbench files; this report is the separately requested deliverable.
|
|
||||||
|
|
||||||
## Concerns
|
|
||||||
|
|
||||||
None. The existing Vite setup reports that no Svelte config is present, but the focused test and production build both pass.
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
# Review package Task 3
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
95dce81 feat: add project channel workbench shell
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
apps/web/src/app/App.svelte | 6 +--
|
|
||||||
.../workbench/ProjectChannelSidebar.svelte | 52 ++++++++++++++++++++++
|
|
||||||
apps/web/src/features/workbench/ProjectRail.svelte | 26 +++++++++++
|
|
||||||
.../src/features/workbench/ProjectWorkbench.svelte | 41 +++++++++++++++++
|
|
||||||
.../features/workbench/ProjectWorkbench.test.ts | 26 +++++++++++
|
|
||||||
.../src/features/workbench/WorkspaceTopbar.svelte | 16 +++++++
|
|
||||||
6 files changed, 163 insertions(+), 4 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
|
|
||||||
index a5d8dce..e7170ef 100644
|
|
||||||
--- a/apps/web/src/app/App.svelte
|
|
||||||
+++ b/apps/web/src/app/App.svelte
|
|
||||||
@@ -1,21 +1,19 @@
|
|
||||||
<script lang="ts">
|
|
||||||
+ import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
|
||||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
|
||||||
|
|
||||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
|
||||||
let currentUser: { account: string } | null = null;
|
|
||||||
|
|
||||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
|
||||||
apiBase = detail.apiBase;
|
|
||||||
localStorage.setItem('apiBase', apiBase);
|
|
||||||
currentUser = { account: detail.account };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if currentUser}
|
|
||||||
- <main class="workbench-placeholder" aria-label="Project workbench">
|
|
||||||
- <h1>SenlinAI Workbench</h1>
|
|
||||||
- <p>Signed in as {currentUser.account}</p>
|
|
||||||
- </main>
|
|
||||||
+ <ProjectWorkbench {currentUser} />
|
|
||||||
{:else}
|
|
||||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
|
||||||
{/if}
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..40cb1a0
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
|
|
||||||
@@ -0,0 +1,52 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
|
||||||
+
|
|
||||||
+ export let project: WorkbenchProject;
|
|
||||||
+ export let channels: WorkbenchChannel[];
|
|
||||||
+ export let selectedChannelID: string;
|
|
||||||
+ export let tags: string[];
|
|
||||||
+ export let recentSessions: AISessionItem[];
|
|
||||||
+ export let onSelectChannel: (channelID: string) => void;
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<aside class="channel-sidebar" aria-label="Project channels">
|
|
||||||
+ <header>
|
|
||||||
+ <div>
|
|
||||||
+ <p>Current project</p>
|
|
||||||
+ <h2>{project.name}</h2>
|
|
||||||
+ </div>
|
|
||||||
+ <button aria-label="Project settings">⚙</button>
|
|
||||||
+ </header>
|
|
||||||
+
|
|
||||||
+ <section class="tag-row" aria-label="Project tags">
|
|
||||||
+ {#each tags as tag}
|
|
||||||
+ <button type="button">#{tag}</button>
|
|
||||||
+ {/each}
|
|
||||||
+ </section>
|
|
||||||
+
|
|
||||||
+ <section class="channel-group">
|
|
||||||
+ {#each channels as channel}
|
|
||||||
+ <button
|
|
||||||
+ class:active={channel.id === selectedChannelID}
|
|
||||||
+ aria-pressed={channel.id === selectedChannelID}
|
|
||||||
+ aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
|
||||||
+ on:click={() => onSelectChannel(channel.id)}
|
|
||||||
+ >
|
|
||||||
+ <span>{channel.title}</span>
|
|
||||||
+ {#if channel.count !== undefined}
|
|
||||||
+ <small>{channel.count}</small>
|
|
||||||
+ {/if}
|
|
||||||
+ </button>
|
|
||||||
+ {/each}
|
|
||||||
+ </section>
|
|
||||||
+
|
|
||||||
+ <section class="recent-sessions" aria-label="Recent sessions">
|
|
||||||
+ <h3>Recent sessions</h3>
|
|
||||||
+ {#each recentSessions as session}
|
|
||||||
+ <button type="button">
|
|
||||||
+ <span>{session.title}</span>
|
|
||||||
+ <small>{session.updatedAt}</small>
|
|
||||||
+ </button>
|
|
||||||
+ {/each}
|
|
||||||
+ </section>
|
|
||||||
+</aside>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..d1be341
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectRail.svelte
|
|
||||||
@@ -0,0 +1,26 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { WorkbenchProject } from './types';
|
|
||||||
+
|
|
||||||
+ export let projects: WorkbenchProject[];
|
|
||||||
+ export let selectedProjectID: number;
|
|
||||||
+ export let onSelectProject: (projectID: number) => void;
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<nav class="project-rail" aria-label="Project list">
|
|
||||||
+ <button class="rail-logo" aria-label="Dashboard">SA</button>
|
|
||||||
+ {#each projects as project}
|
|
||||||
+ <button
|
|
||||||
+ class:active={project.id === selectedProjectID}
|
|
||||||
+ aria-pressed={project.id === selectedProjectID}
|
|
||||||
+ aria-label={project.name}
|
|
||||||
+ title={project.name}
|
|
||||||
+ on:click={() => onSelectProject(project.id)}
|
|
||||||
+ >
|
|
||||||
+ <span>{project.initials}</span>
|
|
||||||
+ {#if project.unreadCount}
|
|
||||||
+ <small>{project.unreadCount}</small>
|
|
||||||
+ {/if}
|
|
||||||
+ </button>
|
|
||||||
+ {/each}
|
|
||||||
+ <button class="add-project" aria-label="Create project">+</button>
|
|
||||||
+</nav>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..4ab9ffd
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
@@ -0,0 +1,41 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
+ import ProjectRail from './ProjectRail.svelte';
|
|
||||||
+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
|
||||||
+ import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
|
||||||
+
|
|
||||||
+ export let currentUser: { account: string };
|
|
||||||
+
|
|
||||||
+ let selectedProjectID = workbenchProjects[0].id;
|
|
||||||
+ $: workspace = getProjectWorkspace(selectedProjectID);
|
|
||||||
+ $: selectedChannelID = workspace.channels[0].id;
|
|
||||||
+
|
|
||||||
+ function selectProject(projectID: number) {
|
|
||||||
+ selectedProjectID = projectID;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ function selectChannel(channelID: string) {
|
|
||||||
+ selectedChannelID = channelID;
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<main class="workbench-shell">
|
|
||||||
+ <WorkspaceTopbar account={currentUser.account} />
|
|
||||||
+ <div class="workbench-body">
|
|
||||||
+ <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
|
||||||
+ <ProjectChannelSidebar
|
|
||||||
+ project={workspace.project}
|
|
||||||
+ channels={workspace.channels}
|
|
||||||
+ {selectedChannelID}
|
|
||||||
+ tags={workspace.tags}
|
|
||||||
+ recentSessions={workspace.recentSessions}
|
|
||||||
+ onSelectChannel={selectChannel}
|
|
||||||
+ />
|
|
||||||
+ <section class="channel-stage" aria-label="Channel content">
|
|
||||||
+ <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
|
||||||
+ </section>
|
|
||||||
+ <aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
+ </aside>
|
|
||||||
+ </div>
|
|
||||||
+</main>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..cf251b3
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
@@ -0,0 +1,26 @@
|
|
||||||
+import '@testing-library/jest-dom/vitest';
|
|
||||||
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
+import { describe, expect, it } from 'vitest';
|
|
||||||
+import ProjectWorkbench from './ProjectWorkbench.svelte';
|
|
||||||
+
|
|
||||||
+describe('ProjectWorkbench', () => {
|
|
||||||
+ it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
|
||||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
+
|
|
||||||
+ expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
|
||||||
+ expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('refreshes channels when switching projects', async () => {
|
|
||||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
|
||||||
+
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+});
|
|
||||||
diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..c828fcf
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
|
||||||
@@ -0,0 +1,16 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ export let account: string;
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<header class="workspace-topbar">
|
|
||||||
+ <div class="brand-mark">SenlinAI</div>
|
|
||||||
+ <label class="search-box">
|
|
||||||
+ <span>Search</span>
|
|
||||||
+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
|
||||||
+ </label>
|
|
||||||
+ <div class="topbar-actions">
|
|
||||||
+ <button aria-label="Back">←</button>
|
|
||||||
+ <button aria-label="Forward">→</button>
|
|
||||||
+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
|
||||||
+ </div>
|
|
||||||
+</header>
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
## Task 4: Channel Content Templates And Object Inspector
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `apps/web/src/features/workbench/ChannelContent.svelte`
|
|
||||||
- Create: `apps/web/src/features/workbench/ObjectInspector.svelte`
|
|
||||||
- Create: channel components under `apps/web/src/features/workbench/channels/`
|
|
||||||
- Modify: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
|
||||||
- Test: `apps/web/src/features/workbench/ChannelContent.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `ProjectWorkspace`
|
|
||||||
- Consumes: selected `WorkbenchChannel`
|
|
||||||
- Produces: `onInspect(item: InspectorItem): void`
|
|
||||||
- Produces: per-channel headings and selectable records.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing channel content test**
|
|
||||||
|
|
||||||
Create `apps/web/src/features/workbench/ChannelContent.test.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import ChannelContent from './ChannelContent.svelte';
|
|
||||||
import { getProjectWorkspace } from './mockData';
|
|
||||||
|
|
||||||
describe('ChannelContent', () => {
|
|
||||||
const workspace = getProjectWorkspace(1);
|
|
||||||
|
|
||||||
it('renders different templates for system and custom channels', () => {
|
|
||||||
for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
|
|
||||||
const channel = workspace.channels.find((item) => item.type === type);
|
|
||||||
if (!channel) throw new Error(`missing ${type}`);
|
|
||||||
|
|
||||||
render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(screen.getByText('Open external channel')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends selected task details to inspector', async () => {
|
|
||||||
let inspectedTitle = '';
|
|
||||||
const channel = workspace.channels.find((item) => item.type === 'tasks');
|
|
||||||
if (!channel) throw new Error('missing task channel');
|
|
||||||
|
|
||||||
render(ChannelContent, {
|
|
||||||
props: {
|
|
||||||
workspace,
|
|
||||||
channel,
|
|
||||||
onInspect: (item) => {
|
|
||||||
inspectedTitle = item.title;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
expect(inspectedTitle).toBe('Confirm homepage information architecture');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/ChannelContent.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because `ChannelContent.svelte` does not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `ChannelContent.svelte` dispatcher**
|
|
||||||
|
|
||||||
Create a dispatcher that selects the channel component by `channel.type`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types';
|
|
||||||
import OverviewChannel from './channels/OverviewChannel.svelte';
|
|
||||||
import InboxChannel from './channels/InboxChannel.svelte';
|
|
||||||
import TasksChannel from './channels/TasksChannel.svelte';
|
|
||||||
import AISessionsChannel from './channels/AISessionsChannel.svelte';
|
|
||||||
import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
|
|
||||||
import CronChannel from './channels/CronChannel.svelte';
|
|
||||||
import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
|
|
||||||
|
|
||||||
export let workspace: ProjectWorkspace;
|
|
||||||
export let channel: WorkbenchChannel;
|
|
||||||
export let onInspect: (item: InspectorItem) => void;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if channel.type === 'overview'}
|
|
||||||
<OverviewChannel {workspace} {onInspect} />
|
|
||||||
{:else if channel.type === 'inbox'}
|
|
||||||
<InboxChannel messages={workspace.inbox} {onInspect} />
|
|
||||||
{:else if channel.type === 'tasks'}
|
|
||||||
<TasksChannel tasks={workspace.tasks} {onInspect} />
|
|
||||||
{:else if channel.type === 'ai_sessions'}
|
|
||||||
<AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
|
|
||||||
{:else if channel.type === 'notes_sources'}
|
|
||||||
<NotesSourcesChannel items={workspace.notesSources} {onInspect} />
|
|
||||||
{:else if channel.type === 'cron'}
|
|
||||||
<CronChannel plans={workspace.cronPlans} {onInspect} />
|
|
||||||
{:else}
|
|
||||||
<CustomLinkChannel {channel} />
|
|
||||||
{/if}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Implement channel components**
|
|
||||||
|
|
||||||
Each channel component should render its own page template and call `onInspect` for selectable records. Example for `TasksChannel.svelte`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import type { InspectorItem, WorkTask } from '../types';
|
|
||||||
export let tasks: WorkTask[];
|
|
||||||
export let onInspect: (item: InspectorItem) => void;
|
|
||||||
|
|
||||||
function inspectTask(task: WorkTask) {
|
|
||||||
onInspect({
|
|
||||||
title: task.title,
|
|
||||||
type: 'Task',
|
|
||||||
description: task.summary,
|
|
||||||
properties: [
|
|
||||||
{ label: 'Status', value: task.completed ? 'Completed' : 'Open' },
|
|
||||||
{ label: 'Owner', value: task.owner },
|
|
||||||
{ label: 'Due', value: task.due },
|
|
||||||
{ label: 'Tag', value: task.tag },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="channel-page">
|
|
||||||
<header class="channel-header">
|
|
||||||
<p>Work Plan</p>
|
|
||||||
<h1>Tasks</h1>
|
|
||||||
</header>
|
|
||||||
<div class="task-list">
|
|
||||||
{#each tasks as task}
|
|
||||||
<article class:completed={task.completed} class="task-card">
|
|
||||||
<span class="task-check" aria-hidden="true">{task.completed ? '鉁? : '鈼?}</span>
|
|
||||||
<div>
|
|
||||||
<h2>{task.title}</h2>
|
|
||||||
<p>{task.summary}</p>
|
|
||||||
<small>{task.owner} 路 {task.due} 路 #{task.tag}</small>
|
|
||||||
</div>
|
|
||||||
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
```
|
|
||||||
|
|
||||||
Implement the remaining channel components with the same pattern:
|
|
||||||
|
|
||||||
- `OverviewChannel.svelte`: metrics for inbox, tasks, AI sessions, notes/sources, cron plans.
|
|
||||||
- `InboxChannel.svelte`: email-like message list with source, title, summary, status, tag, time.
|
|
||||||
- `AISessionsChannel.svelte`: session list plus selected conversation summary area.
|
|
||||||
- `NotesSourcesChannel.svelte`: file-manager-like list of note/file/link records.
|
|
||||||
- `CronChannel.svelte`: scheduled task rows with enabled state, schedule, next run, last result.
|
|
||||||
- `CustomLinkChannel.svelte`: external URL page with `Open external channel` link and copy-style button.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Implement `ObjectInspector.svelte` and wire it into `ProjectWorkbench.svelte`**
|
|
||||||
|
|
||||||
Create `ObjectInspector.svelte`:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import type { InspectorItem } from './types';
|
|
||||||
export let item: InspectorItem | null;
|
|
||||||
let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
<div class="inspector-tabs" role="tablist" aria-label="Inspector tabs">
|
|
||||||
<button aria-selected={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
|
||||||
<button aria-selected={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
|
||||||
<button aria-selected={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if item}
|
|
||||||
<h2>{item.title}</h2>
|
|
||||||
<p>{item.description}</p>
|
|
||||||
{#if activeTab === 'discussion'}
|
|
||||||
<p>No comments yet. Add project discussion here later.</p>
|
|
||||||
{:else if activeTab === 'properties'}
|
|
||||||
<dl>
|
|
||||||
{#each item.properties as property}
|
|
||||||
<div>
|
|
||||||
<dt>{property.label}</dt>
|
|
||||||
<dd>{property.value}</dd>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</dl>
|
|
||||||
{:else}
|
|
||||||
<button type="button">Copy link</button>
|
|
||||||
<button type="button">Archive</button>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<h2>Inspector</h2>
|
|
||||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
{/if}
|
|
||||||
</aside>
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `ProjectWorkbench.svelte` so `ChannelContent` receives the selected channel and `ObjectInspector` receives selected item.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run channel tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\src\features\workbench
|
|
||||||
git commit -m "feat: add workbench channel templates"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
# Review package Task 4 v2
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
1a82a8b fix: address workbench inspector review findings
|
|
||||||
3194cbe feat: add workbench channel templates
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
.superpowers/sdd/task-4-report.md | 67 ++++++++++++++++++++++
|
|
||||||
.../src/features/workbench/ChannelContent.svelte | 30 ++++++++++
|
|
||||||
.../src/features/workbench/ChannelContent.test.ts | 39 +++++++++++++
|
|
||||||
.../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++
|
|
||||||
.../src/features/workbench/ProjectWorkbench.svelte | 15 +++--
|
|
||||||
.../features/workbench/ProjectWorkbench.test.ts | 22 ++++++-
|
|
||||||
.../workbench/channels/AISessionsChannel.svelte | 27 +++++++++
|
|
||||||
.../features/workbench/channels/CronChannel.svelte | 19 ++++++
|
|
||||||
.../workbench/channels/CustomLinkChannel.svelte | 16 ++++++
|
|
||||||
.../workbench/channels/InboxChannel.svelte | 22 +++++++
|
|
||||||
.../workbench/channels/NotesSourcesChannel.svelte | 19 ++++++
|
|
||||||
.../workbench/channels/OverviewChannel.svelte | 29 ++++++++++
|
|
||||||
.../workbench/channels/TasksChannel.svelte | 23 ++++++++
|
|
||||||
13 files changed, 360 insertions(+), 5 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..b743d8d
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/.superpowers/sdd/task-4-report.md
|
|
||||||
@@ -0,0 +1,67 @@
|
|
||||||
+# Task 4 Report: Channel Content Templates And Object Inspector
|
|
||||||
+
|
|
||||||
+## Status
|
|
||||||
+
|
|
||||||
+Implemented and committed the Task 4 workbench channel templates and object inspector.
|
|
||||||
+
|
|
||||||
+## What Changed
|
|
||||||
+
|
|
||||||
+- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template.
|
|
||||||
+- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links.
|
|
||||||
+- Made all applicable records selectable through an `Inspect <title>` action that supplies an `InspectorItem` to the workbench.
|
|
||||||
+- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties.
|
|
||||||
+- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`.
|
|
||||||
+- Added the required channel dispatcher test, including the task-to-inspector callback assertion.
|
|
||||||
+- Kept visual-system CSS untouched for Task 5.
|
|
||||||
+
|
|
||||||
+## TDD Evidence
|
|
||||||
+
|
|
||||||
+1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component.
|
|
||||||
+2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`.
|
|
||||||
+3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist.
|
|
||||||
+4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests.
|
|
||||||
+5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings.
|
|
||||||
+
|
|
||||||
+## Tests
|
|
||||||
+
|
|
||||||
+- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts`
|
|
||||||
+ - Passed: 2 test files, 4 tests.
|
|
||||||
+- `npm run build`
|
|
||||||
+ - Passed: Vite production build completed successfully.
|
|
||||||
+- `git diff --check`
|
|
||||||
+ - Passed: no whitespace errors.
|
|
||||||
+
|
|
||||||
+## Files Changed
|
|
||||||
+
|
|
||||||
+- Created `apps/web/src/features/workbench/ChannelContent.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/ChannelContent.test.ts`
|
|
||||||
+- Created `apps/web/src/features/workbench/ObjectInspector.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/CronChannel.svelte`
|
|
||||||
+- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
|
|
||||||
+- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
|
||||||
+- Created this report: `.superpowers/sdd/task-4-report.md`
|
|
||||||
+
|
|
||||||
+## Self-Review
|
|
||||||
+
|
|
||||||
+- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher.
|
|
||||||
+- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action.
|
|
||||||
+- Confirmed task inspection produces the exact task title required by the test.
|
|
||||||
+- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`.
|
|
||||||
+- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced.
|
|
||||||
+- Confirmed unrelated existing `.superpowers/sdd` files were not staged.
|
|
||||||
+
|
|
||||||
+## Concerns
|
|
||||||
+
|
|
||||||
+- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data.
|
|
||||||
+- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work.
|
|
||||||
+
|
|
||||||
+## Review Fixes
|
|
||||||
+
|
|
||||||
+- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model.
|
|
||||||
+- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector.
|
|
||||||
+- Focused workbench coverage passed after the changes: 2 test files, 5 tests.
|
|
||||||
diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..9e81962
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
||||||
@@ -0,0 +1,30 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types';
|
|
||||||
+ import OverviewChannel from './channels/OverviewChannel.svelte';
|
|
||||||
+ import InboxChannel from './channels/InboxChannel.svelte';
|
|
||||||
+ import TasksChannel from './channels/TasksChannel.svelte';
|
|
||||||
+ import AISessionsChannel from './channels/AISessionsChannel.svelte';
|
|
||||||
+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
|
|
||||||
+ import CronChannel from './channels/CronChannel.svelte';
|
|
||||||
+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
|
|
||||||
+
|
|
||||||
+ export let workspace: ProjectWorkspace;
|
|
||||||
+ export let channel: WorkbenchChannel;
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+{#if channel.type === 'overview'}
|
|
||||||
+ <OverviewChannel {workspace} {onInspect} />
|
|
||||||
+{:else if channel.type === 'inbox'}
|
|
||||||
+ <InboxChannel messages={workspace.inbox} {onInspect} />
|
|
||||||
+{:else if channel.type === 'tasks'}
|
|
||||||
+ <TasksChannel tasks={workspace.tasks} {onInspect} />
|
|
||||||
+{:else if channel.type === 'ai_sessions'}
|
|
||||||
+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
|
|
||||||
+{:else if channel.type === 'notes_sources'}
|
|
||||||
+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} />
|
|
||||||
+{:else if channel.type === 'cron'}
|
|
||||||
+ <CronChannel plans={workspace.cronPlans} {onInspect} />
|
|
||||||
+{:else}
|
|
||||||
+ <CustomLinkChannel {channel} />
|
|
||||||
+{/if}
|
|
||||||
diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..41c4368
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
||||||
@@ -0,0 +1,39 @@
|
|
||||||
+import '@testing-library/jest-dom/vitest';
|
|
||||||
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
+import { describe, expect, it } from 'vitest';
|
|
||||||
+import ChannelContent from './ChannelContent.svelte';
|
|
||||||
+import { getProjectWorkspace } from './mockData';
|
|
||||||
+
|
|
||||||
+describe('ChannelContent', () => {
|
|
||||||
+ const workspace = getProjectWorkspace(1);
|
|
||||||
+
|
|
||||||
+ it('renders different templates for system and custom channels', () => {
|
|
||||||
+ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
|
|
||||||
+ const channel = workspace.channels.find((item) => item.type === type);
|
|
||||||
+ if (!channel) throw new Error(`missing ${type}`);
|
|
||||||
+
|
|
||||||
+ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ expect(screen.getByText('Open external channel')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('sends selected task details to inspector', async () => {
|
|
||||||
+ let inspectedTitle = '';
|
|
||||||
+ const channel = workspace.channels.find((item) => item.type === 'tasks');
|
|
||||||
+ if (!channel) throw new Error('missing task channel');
|
|
||||||
+
|
|
||||||
+ render(ChannelContent, {
|
|
||||||
+ props: {
|
|
||||||
+ workspace,
|
|
||||||
+ channel,
|
|
||||||
+ onInspect: (item) => {
|
|
||||||
+ inspectedTitle = item.title;
|
|
||||||
+ },
|
|
||||||
+ },
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
+ expect(inspectedTitle).toBe('Confirm homepage information architecture');
|
|
||||||
+ });
|
|
||||||
+});
|
|
||||||
diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..79fa781
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
||||||
@@ -0,0 +1,37 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem } from './types';
|
|
||||||
+
|
|
||||||
+ export let item: InspectorItem | null;
|
|
||||||
+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
+ <div class="inspector-tabs">
|
|
||||||
+ <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
|
||||||
+ <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
|
||||||
+ <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
|
|
||||||
+ </div>
|
|
||||||
+
|
|
||||||
+ {#if item}
|
|
||||||
+ <h2>{item.title}</h2>
|
|
||||||
+ <p>{item.description}</p>
|
|
||||||
+ {#if activeTab === 'discussion'}
|
|
||||||
+ <p>No comments yet. Add project discussion here later.</p>
|
|
||||||
+ {:else if activeTab === 'properties'}
|
|
||||||
+ <dl>
|
|
||||||
+ {#each item.properties as property}
|
|
||||||
+ <div>
|
|
||||||
+ <dt>{property.label}</dt>
|
|
||||||
+ <dd>{property.value}</dd>
|
|
||||||
+ </div>
|
|
||||||
+ {/each}
|
|
||||||
+ </dl>
|
|
||||||
+ {:else}
|
|
||||||
+ <button type="button">Copy link</button>
|
|
||||||
+ <button type="button">Archive</button>
|
|
||||||
+ {/if}
|
|
||||||
+ {:else}
|
|
||||||
+ <h2>Inspector</h2>
|
|
||||||
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
+ {/if}
|
|
||||||
+</aside>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
index 4ab9ffd..3d29c30 100644
|
|
||||||
--- a/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
@@ -1,41 +1,48 @@
|
|
||||||
<script lang="ts">
|
|
||||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
+ import ChannelContent from './ChannelContent.svelte';
|
|
||||||
+ import ObjectInspector from './ObjectInspector.svelte';
|
|
||||||
import ProjectRail from './ProjectRail.svelte';
|
|
||||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
|
||||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
|
||||||
+ import type { InspectorItem } from './types';
|
|
||||||
|
|
||||||
export let currentUser: { account: string };
|
|
||||||
|
|
||||||
let selectedProjectID = workbenchProjects[0].id;
|
|
||||||
+ let selectedItem: InspectorItem | null = null;
|
|
||||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
|
||||||
$: selectedChannelID = workspace.channels[0].id;
|
|
||||||
+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
|
|
||||||
|
|
||||||
function selectProject(projectID: number) {
|
|
||||||
selectedProjectID = projectID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectChannel(channelID: string) {
|
|
||||||
selectedChannelID = channelID;
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ function inspectItem(item: InspectorItem) {
|
|
||||||
+ selectedItem = item;
|
|
||||||
+ }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="workbench-shell">
|
|
||||||
<WorkspaceTopbar account={currentUser.account} />
|
|
||||||
<div class="workbench-body">
|
|
||||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
|
||||||
<ProjectChannelSidebar
|
|
||||||
project={workspace.project}
|
|
||||||
channels={workspace.channels}
|
|
||||||
{selectedChannelID}
|
|
||||||
tags={workspace.tags}
|
|
||||||
recentSessions={workspace.recentSessions}
|
|
||||||
onSelectChannel={selectChannel}
|
|
||||||
/>
|
|
||||||
<section class="channel-stage" aria-label="Channel content">
|
|
||||||
- <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
|
||||||
+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} />
|
|
||||||
</section>
|
|
||||||
- <aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
- <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
- </aside>
|
|
||||||
+ <ObjectInspector item={selectedItem} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
index cf251b3..61e15b1 100644
|
|
||||||
--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
@@ -1,12 +1,12 @@
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
-import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
+import { fireEvent, render, screen, within } from '@testing-library/svelte';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
|
||||||
|
|
||||||
describe('ProjectWorkbench', () => {
|
|
||||||
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
|
||||||
@@ -16,11 +16,31 @@ describe('ProjectWorkbench', () => {
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes channels when switching projects', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
+
|
|
||||||
+ it('shows inspected task details in the inspector', async () => {
|
|
||||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
+ const inspector = screen.getByLabelText('Object inspector');
|
|
||||||
+
|
|
||||||
+ expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
+
|
|
||||||
+ expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
|
|
||||||
+
|
|
||||||
+ expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ expect(within(inspector).getByText('Status')).toBeInTheDocument();
|
|
||||||
+ expect(within(inspector).getByText('Open')).toBeInTheDocument();
|
|
||||||
+ expect(within(inspector).getByText('Owner')).toBeInTheDocument();
|
|
||||||
+ expect(within(inspector).getByText('David')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
});
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..3b52383
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
||||||
@@ -0,0 +1,27 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { AISessionItem, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let sessions: AISessionItem[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+ let selectedSessionID = sessions[0]?.id;
|
|
||||||
+
|
|
||||||
+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
|
|
||||||
+
|
|
||||||
+ function inspectSession(session: AISessionItem) {
|
|
||||||
+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page ai-sessions-channel">
|
|
||||||
+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
|
|
||||||
+ <div class="session-layout">
|
|
||||||
+ <div class="session-list">
|
|
||||||
+ {#each sessions as session}
|
|
||||||
+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+ {#if selectedSession}
|
|
||||||
+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
|
|
||||||
+ {/if}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..7a6f2c3
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
||||||
@@ -0,0 +1,19 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { CronPlan, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let plans: CronPlan[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectPlan(plan: CronPlan) {
|
|
||||||
+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page cron-channel">
|
|
||||||
+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
|
|
||||||
+ <div class="cron-list">
|
|
||||||
+ {#each plans as plan}
|
|
||||||
+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..0fa9111
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
||||||
@@ -0,0 +1,16 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { WorkbenchChannel } from '../types';
|
|
||||||
+
|
|
||||||
+ export let channel: WorkbenchChannel;
|
|
||||||
+
|
|
||||||
+ function copyUrl() {
|
|
||||||
+ void navigator.clipboard?.writeText(channel.url ?? '');
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page custom-link-channel">
|
|
||||||
+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
|
|
||||||
+ <p>{channel.url}</p>
|
|
||||||
+ <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
|
|
||||||
+ <button type="button" on:click={copyUrl}>Copy URL</button>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..2d25d63
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
||||||
@@ -0,0 +1,22 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InboxMessage, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let messages: InboxMessage[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectMessage(message: InboxMessage) {
|
|
||||||
+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page inbox-channel">
|
|
||||||
+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
|
|
||||||
+ <div class="message-list">
|
|
||||||
+ {#each messages as message}
|
|
||||||
+ <article class="message-row">
|
|
||||||
+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
|
|
||||||
+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
|
|
||||||
+ </article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..256ef8b
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
||||||
@@ -0,0 +1,19 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, NoteSourceItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let items: NoteSourceItem[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectItem(item: NoteSourceItem) {
|
|
||||||
+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page notes-sources-channel">
|
|
||||||
+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
|
|
||||||
+ <div class="file-list">
|
|
||||||
+ {#each items as item}
|
|
||||||
+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..6d478cf
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
||||||
@@ -0,0 +1,29 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, ProjectWorkspace } from '../types';
|
|
||||||
+
|
|
||||||
+ export let workspace: ProjectWorkspace;
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ const metrics = (workspace: ProjectWorkspace) => [
|
|
||||||
+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
|
|
||||||
+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
|
|
||||||
+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
|
|
||||||
+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
|
|
||||||
+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
|
|
||||||
+ ];
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page overview-channel">
|
|
||||||
+ <header class="channel-header">
|
|
||||||
+ <p>{workspace.project.name}</p>
|
|
||||||
+ <h1>Overview</h1>
|
|
||||||
+ </header>
|
|
||||||
+ <div class="metric-list">
|
|
||||||
+ {#each metrics(workspace) as metric}
|
|
||||||
+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
|
|
||||||
+ <strong>{metric.value}</strong>
|
|
||||||
+ <span>{metric.label}</span>
|
|
||||||
+ </button>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..b59e47e
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
||||||
@@ -0,0 +1,23 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, WorkTask } from '../types';
|
|
||||||
+
|
|
||||||
+ export let tasks: WorkTask[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectTask(task: WorkTask) {
|
|
||||||
+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page tasks-channel">
|
|
||||||
+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
|
|
||||||
+ <div class="task-list">
|
|
||||||
+ {#each tasks as task}
|
|
||||||
+ <article class:completed={task.completed} class="task-card">
|
|
||||||
+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span>
|
|
||||||
+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div>
|
|
||||||
+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
|
||||||
+ </article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
# Review package Task 4
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
3194cbe feat: add workbench channel templates
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
.../src/features/workbench/ChannelContent.svelte | 30 +++++++++++++++++
|
|
||||||
.../src/features/workbench/ChannelContent.test.ts | 39 ++++++++++++++++++++++
|
|
||||||
.../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++++++++++
|
|
||||||
.../src/features/workbench/ProjectWorkbench.svelte | 15 ++++++---
|
|
||||||
.../workbench/channels/AISessionsChannel.svelte | 27 +++++++++++++++
|
|
||||||
.../features/workbench/channels/CronChannel.svelte | 19 +++++++++++
|
|
||||||
.../workbench/channels/CustomLinkChannel.svelte | 16 +++++++++
|
|
||||||
.../workbench/channels/InboxChannel.svelte | 22 ++++++++++++
|
|
||||||
.../workbench/channels/NotesSourcesChannel.svelte | 19 +++++++++++
|
|
||||||
.../workbench/channels/OverviewChannel.svelte | 29 ++++++++++++++++
|
|
||||||
.../workbench/channels/TasksChannel.svelte | 23 +++++++++++++
|
|
||||||
11 files changed, 272 insertions(+), 4 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..9e81962
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
||||||
@@ -0,0 +1,30 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types';
|
|
||||||
+ import OverviewChannel from './channels/OverviewChannel.svelte';
|
|
||||||
+ import InboxChannel from './channels/InboxChannel.svelte';
|
|
||||||
+ import TasksChannel from './channels/TasksChannel.svelte';
|
|
||||||
+ import AISessionsChannel from './channels/AISessionsChannel.svelte';
|
|
||||||
+ import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
|
|
||||||
+ import CronChannel from './channels/CronChannel.svelte';
|
|
||||||
+ import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
|
|
||||||
+
|
|
||||||
+ export let workspace: ProjectWorkspace;
|
|
||||||
+ export let channel: WorkbenchChannel;
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+{#if channel.type === 'overview'}
|
|
||||||
+ <OverviewChannel {workspace} {onInspect} />
|
|
||||||
+{:else if channel.type === 'inbox'}
|
|
||||||
+ <InboxChannel messages={workspace.inbox} {onInspect} />
|
|
||||||
+{:else if channel.type === 'tasks'}
|
|
||||||
+ <TasksChannel tasks={workspace.tasks} {onInspect} />
|
|
||||||
+{:else if channel.type === 'ai_sessions'}
|
|
||||||
+ <AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
|
|
||||||
+{:else if channel.type === 'notes_sources'}
|
|
||||||
+ <NotesSourcesChannel items={workspace.notesSources} {onInspect} />
|
|
||||||
+{:else if channel.type === 'cron'}
|
|
||||||
+ <CronChannel plans={workspace.cronPlans} {onInspect} />
|
|
||||||
+{:else}
|
|
||||||
+ <CustomLinkChannel {channel} />
|
|
||||||
+{/if}
|
|
||||||
diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..41c4368
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
||||||
@@ -0,0 +1,39 @@
|
|
||||||
+import '@testing-library/jest-dom/vitest';
|
|
||||||
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
||||||
+import { describe, expect, it } from 'vitest';
|
|
||||||
+import ChannelContent from './ChannelContent.svelte';
|
|
||||||
+import { getProjectWorkspace } from './mockData';
|
|
||||||
+
|
|
||||||
+describe('ChannelContent', () => {
|
|
||||||
+ const workspace = getProjectWorkspace(1);
|
|
||||||
+
|
|
||||||
+ it('renders different templates for system and custom channels', () => {
|
|
||||||
+ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
|
|
||||||
+ const channel = workspace.channels.find((item) => item.type === type);
|
|
||||||
+ if (!channel) throw new Error(`missing ${type}`);
|
|
||||||
+
|
|
||||||
+ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ expect(screen.getByText('Open external channel')).toBeInTheDocument();
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ it('sends selected task details to inspector', async () => {
|
|
||||||
+ let inspectedTitle = '';
|
|
||||||
+ const channel = workspace.channels.find((item) => item.type === 'tasks');
|
|
||||||
+ if (!channel) throw new Error('missing task channel');
|
|
||||||
+
|
|
||||||
+ render(ChannelContent, {
|
|
||||||
+ props: {
|
|
||||||
+ workspace,
|
|
||||||
+ channel,
|
|
||||||
+ onInspect: (item) => {
|
|
||||||
+ inspectedTitle = item.title;
|
|
||||||
+ },
|
|
||||||
+ },
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
+ expect(inspectedTitle).toBe('Confirm homepage information architecture');
|
|
||||||
+ });
|
|
||||||
+});
|
|
||||||
diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..f36f387
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
||||||
@@ -0,0 +1,37 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem } from './types';
|
|
||||||
+
|
|
||||||
+ export let item: InspectorItem | null;
|
|
||||||
+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
+ <div class="inspector-tabs" role="tablist" aria-label="Inspector tabs">
|
|
||||||
+ <button role="tab" aria-selected={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
|
||||||
+ <button role="tab" aria-selected={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
|
||||||
+ <button role="tab" aria-selected={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
|
|
||||||
+ </div>
|
|
||||||
+
|
|
||||||
+ {#if item}
|
|
||||||
+ <h2>{item.title}</h2>
|
|
||||||
+ <p>{item.description}</p>
|
|
||||||
+ {#if activeTab === 'discussion'}
|
|
||||||
+ <p>No comments yet. Add project discussion here later.</p>
|
|
||||||
+ {:else if activeTab === 'properties'}
|
|
||||||
+ <dl>
|
|
||||||
+ {#each item.properties as property}
|
|
||||||
+ <div>
|
|
||||||
+ <dt>{property.label}</dt>
|
|
||||||
+ <dd>{property.value}</dd>
|
|
||||||
+ </div>
|
|
||||||
+ {/each}
|
|
||||||
+ </dl>
|
|
||||||
+ {:else}
|
|
||||||
+ <button type="button">Copy link</button>
|
|
||||||
+ <button type="button">Archive</button>
|
|
||||||
+ {/if}
|
|
||||||
+ {:else}
|
|
||||||
+ <h2>Inspector</h2>
|
|
||||||
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
+ {/if}
|
|
||||||
+</aside>
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
index 4ab9ffd..3d29c30 100644
|
|
||||||
--- a/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
||||||
@@ -1,41 +1,48 @@
|
|
||||||
<script lang="ts">
|
|
||||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
||||||
+ import ChannelContent from './ChannelContent.svelte';
|
|
||||||
+ import ObjectInspector from './ObjectInspector.svelte';
|
|
||||||
import ProjectRail from './ProjectRail.svelte';
|
|
||||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
|
||||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
|
||||||
+ import type { InspectorItem } from './types';
|
|
||||||
|
|
||||||
export let currentUser: { account: string };
|
|
||||||
|
|
||||||
let selectedProjectID = workbenchProjects[0].id;
|
|
||||||
+ let selectedItem: InspectorItem | null = null;
|
|
||||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
|
||||||
$: selectedChannelID = workspace.channels[0].id;
|
|
||||||
+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
|
|
||||||
|
|
||||||
function selectProject(projectID: number) {
|
|
||||||
selectedProjectID = projectID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectChannel(channelID: string) {
|
|
||||||
selectedChannelID = channelID;
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ function inspectItem(item: InspectorItem) {
|
|
||||||
+ selectedItem = item;
|
|
||||||
+ }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="workbench-shell">
|
|
||||||
<WorkspaceTopbar account={currentUser.account} />
|
|
||||||
<div class="workbench-body">
|
|
||||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
|
||||||
<ProjectChannelSidebar
|
|
||||||
project={workspace.project}
|
|
||||||
channels={workspace.channels}
|
|
||||||
{selectedChannelID}
|
|
||||||
tags={workspace.tags}
|
|
||||||
recentSessions={workspace.recentSessions}
|
|
||||||
onSelectChannel={selectChannel}
|
|
||||||
/>
|
|
||||||
<section class="channel-stage" aria-label="Channel content">
|
|
||||||
- <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
|
||||||
+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} />
|
|
||||||
</section>
|
|
||||||
- <aside class="object-inspector" aria-label="Object inspector">
|
|
||||||
- <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
||||||
- </aside>
|
|
||||||
+ <ObjectInspector item={selectedItem} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..3b52383
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
||||||
@@ -0,0 +1,27 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { AISessionItem, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let sessions: AISessionItem[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+ let selectedSessionID = sessions[0]?.id;
|
|
||||||
+
|
|
||||||
+ $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
|
|
||||||
+
|
|
||||||
+ function inspectSession(session: AISessionItem) {
|
|
||||||
+ onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page ai-sessions-channel">
|
|
||||||
+ <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
|
|
||||||
+ <div class="session-layout">
|
|
||||||
+ <div class="session-list">
|
|
||||||
+ {#each sessions as session}
|
|
||||||
+ <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+ {#if selectedSession}
|
|
||||||
+ <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
|
|
||||||
+ {/if}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..7a6f2c3
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
||||||
@@ -0,0 +1,19 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { CronPlan, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let plans: CronPlan[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectPlan(plan: CronPlan) {
|
|
||||||
+ onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page cron-channel">
|
|
||||||
+ <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
|
|
||||||
+ <div class="cron-list">
|
|
||||||
+ {#each plans as plan}
|
|
||||||
+ <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..0fa9111
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
||||||
@@ -0,0 +1,16 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { WorkbenchChannel } from '../types';
|
|
||||||
+
|
|
||||||
+ export let channel: WorkbenchChannel;
|
|
||||||
+
|
|
||||||
+ function copyUrl() {
|
|
||||||
+ void navigator.clipboard?.writeText(channel.url ?? '');
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page custom-link-channel">
|
|
||||||
+ <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
|
|
||||||
+ <p>{channel.url}</p>
|
|
||||||
+ <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
|
|
||||||
+ <button type="button" on:click={copyUrl}>Copy URL</button>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..2d25d63
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
||||||
@@ -0,0 +1,22 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InboxMessage, InspectorItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let messages: InboxMessage[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectMessage(message: InboxMessage) {
|
|
||||||
+ onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page inbox-channel">
|
|
||||||
+ <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
|
|
||||||
+ <div class="message-list">
|
|
||||||
+ {#each messages as message}
|
|
||||||
+ <article class="message-row">
|
|
||||||
+ <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
|
|
||||||
+ <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
|
|
||||||
+ </article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..256ef8b
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
||||||
@@ -0,0 +1,19 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, NoteSourceItem } from '../types';
|
|
||||||
+
|
|
||||||
+ export let items: NoteSourceItem[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectItem(item: NoteSourceItem) {
|
|
||||||
+ onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page notes-sources-channel">
|
|
||||||
+ <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
|
|
||||||
+ <div class="file-list">
|
|
||||||
+ {#each items as item}
|
|
||||||
+ <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..6d478cf
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
||||||
@@ -0,0 +1,29 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, ProjectWorkspace } from '../types';
|
|
||||||
+
|
|
||||||
+ export let workspace: ProjectWorkspace;
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ const metrics = (workspace: ProjectWorkspace) => [
|
|
||||||
+ { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
|
|
||||||
+ { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
|
|
||||||
+ { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
|
|
||||||
+ { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
|
|
||||||
+ { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
|
|
||||||
+ ];
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page overview-channel">
|
|
||||||
+ <header class="channel-header">
|
|
||||||
+ <p>{workspace.project.name}</p>
|
|
||||||
+ <h1>Overview</h1>
|
|
||||||
+ </header>
|
|
||||||
+ <div class="metric-list">
|
|
||||||
+ {#each metrics(workspace) as metric}
|
|
||||||
+ <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
|
|
||||||
+ <strong>{metric.value}</strong>
|
|
||||||
+ <span>{metric.label}</span>
|
|
||||||
+ </button>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..b59e47e
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
||||||
@@ -0,0 +1,23 @@
|
|
||||||
+<script lang="ts">
|
|
||||||
+ import type { InspectorItem, WorkTask } from '../types';
|
|
||||||
+
|
|
||||||
+ export let tasks: WorkTask[];
|
|
||||||
+ export let onInspect: (item: InspectorItem) => void;
|
|
||||||
+
|
|
||||||
+ function inspectTask(task: WorkTask) {
|
|
||||||
+ onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
|
|
||||||
+ }
|
|
||||||
+</script>
|
|
||||||
+
|
|
||||||
+<section class="channel-page tasks-channel">
|
|
||||||
+ <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
|
|
||||||
+ <div class="task-list">
|
|
||||||
+ {#each tasks as task}
|
|
||||||
+ <article class:completed={task.completed} class="task-card">
|
|
||||||
+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span>
|
|
||||||
+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div>
|
|
||||||
+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
|
||||||
+ </article>
|
|
||||||
+ {/each}
|
|
||||||
+ </div>
|
|
||||||
+</section>
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
## Task 5: shadcn/ui-Inspired Visual System In CSS
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/web/src/index.css`
|
|
||||||
- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: class names introduced in Tasks 1-4.
|
|
||||||
- Produces: stable desktop layout, responsive mobile layout, accessible focus states.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add a test assertion for active state semantics**
|
|
||||||
|
|
||||||
Extend `ProjectWorkbench.test.ts` with:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it('marks active channel with aria-pressed', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS before visual CSS work; this protects state semantics while styling changes.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace `index.css` with workbench visual system**
|
|
||||||
|
|
||||||
Implement CSS tokens and component classes:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
color: #18181b;
|
|
||||||
background: #f4f4f5;
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
--background: #f4f4f5;
|
|
||||||
--panel: #ffffff;
|
|
||||||
--panel-muted: #fafafa;
|
|
||||||
--border: #d4d4d8;
|
|
||||||
--text: #18181b;
|
|
||||||
--muted: #71717a;
|
|
||||||
--primary: #0f7ae5;
|
|
||||||
--primary-strong: #0969c8;
|
|
||||||
--success: #15803d;
|
|
||||||
--danger: #b91c1c;
|
|
||||||
--radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
|
||||||
button, input, textarea, select { font: inherit; }
|
|
||||||
button { cursor: pointer; }
|
|
||||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-panel {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--panel);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-login,
|
|
||||||
.login-heading,
|
|
||||||
.channel-page,
|
|
||||||
.object-inspector,
|
|
||||||
.channel-sidebar {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-login input,
|
|
||||||
.search-box input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-login button,
|
|
||||||
.primary-action {
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
padding: 9px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 64px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-topbar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 160px minmax(240px, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--panel);
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench-body {
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-rail,
|
|
||||||
.channel-sidebar,
|
|
||||||
.object-inspector {
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-rail {
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-rail button {
|
|
||||||
min-width: 48px;
|
|
||||||
min-height: 48px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--panel-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-rail button.active,
|
|
||||||
.channel-group button.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
background: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-sidebar,
|
|
||||||
.object-inspector,
|
|
||||||
.channel-stage {
|
|
||||||
padding: 16px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-group,
|
|
||||||
.recent-sessions,
|
|
||||||
.task-list,
|
|
||||||
.record-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-group button,
|
|
||||||
.recent-sessions button,
|
|
||||||
.task-card,
|
|
||||||
.record-row,
|
|
||||||
.cron-row {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--panel);
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-card,
|
|
||||||
.record-row,
|
|
||||||
.cron-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-card.completed {
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--panel-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inspector-tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inspector-tabs button[aria-selected='true'] {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
|
||||||
.workspace-topbar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
height: auto;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench-shell {
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workbench-body {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-rail,
|
|
||||||
.channel-sidebar,
|
|
||||||
.object-inspector {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run component tests and build**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\src\index.css apps\web\src\features\workbench\ProjectWorkbench.test.ts
|
|
||||||
git commit -m "style: apply workbench visual system"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
# Review package Task 5 v2
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
7086b8b fix: style active inspector view
|
|
||||||
092930e style: apply workbench visual system
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
.superpowers/sdd/task-5-report.md | 43 ++++
|
|
||||||
.../features/workbench/ProjectWorkbench.test.ts | 8 +
|
|
||||||
apps/web/src/index.css | 245 ++++++++++++---------
|
|
||||||
3 files changed, 187 insertions(+), 109 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..d4fc752
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/.superpowers/sdd/task-5-report.md
|
|
||||||
@@ -0,0 +1,43 @@
|
|
||||||
+# Task 5 Report: shadcn/ui-Inspired Visual System In CSS
|
|
||||||
+
|
|
||||||
+## Status
|
|
||||||
+
|
|
||||||
+Implemented and committed the Task 5 workbench visual system.
|
|
||||||
+
|
|
||||||
+## What Changed
|
|
||||||
+
|
|
||||||
+- Replaced `apps/web/src/index.css` with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles.
|
|
||||||
+- Added the active-channel `aria-pressed` assertion to `ProjectWorkbench.test.ts`.
|
|
||||||
+
|
|
||||||
+## Tests
|
|
||||||
+
|
|
||||||
+From `apps/web`:
|
|
||||||
+
|
|
||||||
+- `npm test -- --run src/features/workbench/ProjectWorkbench.test.ts` passed: 1 test file, 4 tests.
|
|
||||||
+- `npm test -- --run` passed: 6 test files, 13 tests.
|
|
||||||
+- `npm run build` passed: Vite production build completed successfully.
|
|
||||||
+
|
|
||||||
+## TDD Evidence
|
|
||||||
+
|
|
||||||
+The requested active-channel assertion was added before CSS changes and run immediately. It passed because `ProjectChannelSidebar` already exposed `aria-pressed` for the selected channel; this task preserves that existing semantic contract while styling the component.
|
|
||||||
+
|
|
||||||
+## Files Changed
|
|
||||||
+
|
|
||||||
+- `apps/web/src/index.css`
|
|
||||||
+- `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
|
||||||
+
|
|
||||||
+## Self-Review
|
|
||||||
+
|
|
||||||
+- CSS values and selectors follow the Task 5 brief verbatim.
|
|
||||||
+- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px.
|
|
||||||
+- Keyboard focus is visibly indicated for buttons and inputs.
|
|
||||||
+- No React or shadcn/ui dependencies were added.
|
|
||||||
+- The commit stages only the two task-owned implementation files; pre-existing untracked `.superpowers/sdd` artifacts remain untouched.
|
|
||||||
+
|
|
||||||
+## Concerns
|
|
||||||
+
|
|
||||||
+The original selector mismatch between `aria-selected` and the inspector's existing `aria-pressed` semantics was corrected in the review fix below.
|
|
||||||
+
|
|
||||||
+## Review Fix
|
|
||||||
+
|
|
||||||
+Updated the inspector active-state selector in `apps/web/src/index.css` from `[aria-selected='true']` to `[aria-pressed='true']` so it matches `ObjectInspector`'s existing accessible state without changing component semantics.
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
index 61e15b1..f2b6e72 100644
|
|
||||||
--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => {
|
|
||||||
|
|
||||||
it('refreshes channels when switching projects', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
+ it('marks active channel with aria-pressed', async () => {
|
|
||||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
+
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
it('shows inspected task details in the inspector', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
const inspector = screen.getByLabelText('Object inspector');
|
|
||||||
|
|
||||||
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
|
|
||||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
|
||||||
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
|
|
||||||
index 7a1e945..160edde 100644
|
|
||||||
--- a/apps/web/src/index.css
|
|
||||||
+++ b/apps/web/src/index.css
|
|
||||||
@@ -1,165 +1,192 @@
|
|
||||||
:root {
|
|
||||||
- color: #202124;
|
|
||||||
- background: #f6f7f9;
|
|
||||||
- font-family:
|
|
||||||
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
+ color: #18181b;
|
|
||||||
+ background: #f4f4f5;
|
|
||||||
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-* {
|
|
||||||
- box-sizing: border-box;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-body {
|
|
||||||
- margin: 0;
|
|
||||||
- min-width: 320px;
|
|
||||||
+ --background: #f4f4f5;
|
|
||||||
+ --panel: #ffffff;
|
|
||||||
+ --panel-muted: #fafafa;
|
|
||||||
+ --border: #d4d4d8;
|
|
||||||
+ --text: #18181b;
|
|
||||||
+ --muted: #71717a;
|
|
||||||
+ --primary: #0f7ae5;
|
|
||||||
+ --primary-strong: #0969c8;
|
|
||||||
+ --success: #15803d;
|
|
||||||
+ --danger: #b91c1c;
|
|
||||||
+ --radius: 8px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+* { box-sizing: border-box; }
|
|
||||||
+body { margin: 0; min-width: 320px; min-height: 100vh; }
|
|
||||||
+button, input, textarea, select { font: inherit; }
|
|
||||||
+button { cursor: pointer; }
|
|
||||||
+button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
||||||
+
|
|
||||||
+.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-button,
|
|
||||||
-input,
|
|
||||||
-textarea,
|
|
||||||
-select {
|
|
||||||
- font: inherit;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.shell {
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: 280px 1fr;
|
|
||||||
- min-height: 100vh;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.sidebar {
|
|
||||||
- border-right: 1px solid #d9dde3;
|
|
||||||
- background: #ffffff;
|
|
||||||
+ place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
+ background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.sidebar h1 {
|
|
||||||
- margin: 0 0 24px;
|
|
||||||
- font-size: 24px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.workspace {
|
|
||||||
- display: grid;
|
|
||||||
- gap: 24px;
|
|
||||||
+.login-panel {
|
|
||||||
+ width: min(420px, 100%);
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: var(--radius);
|
|
||||||
+ background: var(--panel);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.connection-status,
|
|
||||||
-.selection-status {
|
|
||||||
- margin: 12px 0 0;
|
|
||||||
- color: #5f6673;
|
|
||||||
- font-size: 13px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.server-login {
|
|
||||||
+.server-login,
|
|
||||||
+.login-heading,
|
|
||||||
+.channel-page,
|
|
||||||
+.object-inspector,
|
|
||||||
+.channel-sidebar {
|
|
||||||
display: grid;
|
|
||||||
- gap: 8px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.server-login label {
|
|
||||||
- font-size: 13px;
|
|
||||||
- color: #4b5563;
|
|
||||||
+ gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.server-login input {
|
|
||||||
+.server-login input,
|
|
||||||
+.search-box input {
|
|
||||||
width: 100%;
|
|
||||||
- border: 1px solid #c7ccd4;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
+ background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.server-login button {
|
|
||||||
- border: 1px solid #1f2937;
|
|
||||||
+.server-login button,
|
|
||||||
+.primary-action {
|
|
||||||
+ border: 1px solid var(--primary);
|
|
||||||
border-radius: 6px;
|
|
||||||
- background: #1f2937;
|
|
||||||
+ background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
- padding: 9px 10px;
|
|
||||||
- cursor: pointer;
|
|
||||||
+ padding: 9px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-grid {
|
|
||||||
+.workbench-shell {
|
|
||||||
+ min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: repeat(4, minmax(160px, 1fr));
|
|
||||||
- gap: 12px;
|
|
||||||
+ grid-template-rows: 64px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric {
|
|
||||||
- border: 1px solid #d9dde3;
|
|
||||||
- border-radius: 8px;
|
|
||||||
- background: white;
|
|
||||||
- padding: 16px;
|
|
||||||
+.workspace-topbar {
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: 160px minmax(240px, 1fr) auto;
|
|
||||||
+ align-items: center;
|
|
||||||
+ gap: 16px;
|
|
||||||
+ border-bottom: 1px solid var(--border);
|
|
||||||
+ background: var(--panel);
|
|
||||||
+ padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric span {
|
|
||||||
- display: block;
|
|
||||||
- color: #5f6673;
|
|
||||||
- font-size: 14px;
|
|
||||||
+.workbench-body {
|
|
||||||
+ min-height: 0;
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric strong {
|
|
||||||
- display: block;
|
|
||||||
- margin-top: 8px;
|
|
||||||
- font-size: 28px;
|
|
||||||
+.project-rail,
|
|
||||||
+.channel-sidebar,
|
|
||||||
+.object-inspector {
|
|
||||||
+ border-right: 1px solid var(--border);
|
|
||||||
+ background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.inbox-review {
|
|
||||||
+.project-rail {
|
|
||||||
display: grid;
|
|
||||||
- gap: 12px;
|
|
||||||
+ align-content: start;
|
|
||||||
+ gap: 10px;
|
|
||||||
+ padding: 12px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.project-rail button {
|
|
||||||
+ min-width: 48px;
|
|
||||||
+ min-height: 48px;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: 999px;
|
|
||||||
+ background: var(--panel-muted);
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.project-rail button.active,
|
|
||||||
+.channel-group button.active {
|
|
||||||
+ border-color: var(--primary);
|
|
||||||
+ color: var(--primary);
|
|
||||||
+ background: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.inbox-review h2 {
|
|
||||||
- margin: 0;
|
|
||||||
- font-size: 18px;
|
|
||||||
+.channel-sidebar,
|
|
||||||
+.object-inspector,
|
|
||||||
+.channel-stage {
|
|
||||||
+ padding: 16px;
|
|
||||||
+ overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestions {
|
|
||||||
+.channel-group,
|
|
||||||
+.recent-sessions,
|
|
||||||
+.task-list,
|
|
||||||
+.record-list {
|
|
||||||
display: grid;
|
|
||||||
- gap: 10px;
|
|
||||||
+ gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion {
|
|
||||||
+.channel-group button,
|
|
||||||
+.recent-sessions button,
|
|
||||||
+.task-card,
|
|
||||||
+.record-row,
|
|
||||||
+.cron-row {
|
|
||||||
+ width: 100%;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: var(--radius);
|
|
||||||
+ background: var(--panel);
|
|
||||||
+ padding: 10px 12px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.task-card,
|
|
||||||
+.record-row,
|
|
||||||
+.cron-row {
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: 20px 1fr;
|
|
||||||
- gap: 10px;
|
|
||||||
+ grid-template-columns: auto 1fr auto;
|
|
||||||
+ gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
- border: 1px solid #d9dde3;
|
|
||||||
- border-radius: 8px;
|
|
||||||
- background: white;
|
|
||||||
- padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion-body {
|
|
||||||
- display: grid;
|
|
||||||
- gap: 4px;
|
|
||||||
+.task-card.completed {
|
|
||||||
+ color: var(--muted);
|
|
||||||
+ background: var(--panel-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion-body small {
|
|
||||||
- color: #5f6673;
|
|
||||||
+.inspector-tabs {
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: repeat(3, 1fr);
|
|
||||||
+ gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestions button {
|
|
||||||
- justify-self: start;
|
|
||||||
- border: 1px solid #1f2937;
|
|
||||||
- border-radius: 6px;
|
|
||||||
- background: #1f2937;
|
|
||||||
- color: white;
|
|
||||||
- padding: 9px 12px;
|
|
||||||
- cursor: pointer;
|
|
||||||
+.inspector-tabs button[aria-pressed='true'] {
|
|
||||||
+ border-color: var(--primary);
|
|
||||||
+ color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
-@media (max-width: 760px) {
|
|
||||||
- .shell {
|
|
||||||
+@media (max-width: 920px) {
|
|
||||||
+ .workspace-topbar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
+ height: auto;
|
|
||||||
+ padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
- .sidebar {
|
|
||||||
- border-right: 0;
|
|
||||||
- border-bottom: 1px solid #d9dde3;
|
|
||||||
+ .workbench-shell {
|
|
||||||
+ grid-template-rows: auto 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
- .dashboard-grid {
|
|
||||||
- grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
+ .workbench-body {
|
|
||||||
+ grid-template-columns: 1fr;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ .project-rail,
|
|
||||||
+ .channel-sidebar,
|
|
||||||
+ .object-inspector {
|
|
||||||
+ border-right: 0;
|
|
||||||
+ border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
# Review package Task 5
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
092930e style: apply workbench visual system
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
.../features/workbench/ProjectWorkbench.test.ts | 8 +
|
|
||||||
apps/web/src/index.css | 245 ++++++++++++---------
|
|
||||||
2 files changed, 144 insertions(+), 109 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
index 61e15b1..f2b6e72 100644
|
|
||||||
--- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
||||||
@@ -17,20 +17,28 @@ describe('ProjectWorkbench', () => {
|
|
||||||
|
|
||||||
it('refreshes channels when switching projects', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
+ it('marks active channel with aria-pressed', async () => {
|
|
||||||
+ render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
+
|
|
||||||
+ await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
+
|
|
||||||
+ expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ });
|
|
||||||
+
|
|
||||||
it('shows inspected task details in the inspector', async () => {
|
|
||||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
|
||||||
const inspector = screen.getByLabelText('Object inspector');
|
|
||||||
|
|
||||||
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
|
||||||
|
|
||||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
|
||||||
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
|
|
||||||
index 7a1e945..5a9f932 100644
|
|
||||||
--- a/apps/web/src/index.css
|
|
||||||
+++ b/apps/web/src/index.css
|
|
||||||
@@ -1,165 +1,192 @@
|
|
||||||
:root {
|
|
||||||
- color: #202124;
|
|
||||||
- background: #f6f7f9;
|
|
||||||
- font-family:
|
|
||||||
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
+ color: #18181b;
|
|
||||||
+ background: #f4f4f5;
|
|
||||||
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-* {
|
|
||||||
- box-sizing: border-box;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-body {
|
|
||||||
- margin: 0;
|
|
||||||
- min-width: 320px;
|
|
||||||
+ --background: #f4f4f5;
|
|
||||||
+ --panel: #ffffff;
|
|
||||||
+ --panel-muted: #fafafa;
|
|
||||||
+ --border: #d4d4d8;
|
|
||||||
+ --text: #18181b;
|
|
||||||
+ --muted: #71717a;
|
|
||||||
+ --primary: #0f7ae5;
|
|
||||||
+ --primary-strong: #0969c8;
|
|
||||||
+ --success: #15803d;
|
|
||||||
+ --danger: #b91c1c;
|
|
||||||
+ --radius: 8px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+* { box-sizing: border-box; }
|
|
||||||
+body { margin: 0; min-width: 320px; min-height: 100vh; }
|
|
||||||
+button, input, textarea, select { font: inherit; }
|
|
||||||
+button { cursor: pointer; }
|
|
||||||
+button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
||||||
+
|
|
||||||
+.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-button,
|
|
||||||
-input,
|
|
||||||
-textarea,
|
|
||||||
-select {
|
|
||||||
- font: inherit;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.shell {
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: 280px 1fr;
|
|
||||||
- min-height: 100vh;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.sidebar {
|
|
||||||
- border-right: 1px solid #d9dde3;
|
|
||||||
- background: #ffffff;
|
|
||||||
+ place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
+ background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.sidebar h1 {
|
|
||||||
- margin: 0 0 24px;
|
|
||||||
- font-size: 24px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.workspace {
|
|
||||||
- display: grid;
|
|
||||||
- gap: 24px;
|
|
||||||
+.login-panel {
|
|
||||||
+ width: min(420px, 100%);
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: var(--radius);
|
|
||||||
+ background: var(--panel);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.connection-status,
|
|
||||||
-.selection-status {
|
|
||||||
- margin: 12px 0 0;
|
|
||||||
- color: #5f6673;
|
|
||||||
- font-size: 13px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.server-login {
|
|
||||||
+.server-login,
|
|
||||||
+.login-heading,
|
|
||||||
+.channel-page,
|
|
||||||
+.object-inspector,
|
|
||||||
+.channel-sidebar {
|
|
||||||
display: grid;
|
|
||||||
- gap: 8px;
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-.server-login label {
|
|
||||||
- font-size: 13px;
|
|
||||||
- color: #4b5563;
|
|
||||||
+ gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.server-login input {
|
|
||||||
+.server-login input,
|
|
||||||
+.search-box input {
|
|
||||||
width: 100%;
|
|
||||||
- border: 1px solid #c7ccd4;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
+ background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.server-login button {
|
|
||||||
- border: 1px solid #1f2937;
|
|
||||||
+.server-login button,
|
|
||||||
+.primary-action {
|
|
||||||
+ border: 1px solid var(--primary);
|
|
||||||
border-radius: 6px;
|
|
||||||
- background: #1f2937;
|
|
||||||
+ background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
- padding: 9px 10px;
|
|
||||||
- cursor: pointer;
|
|
||||||
+ padding: 9px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-grid {
|
|
||||||
+.workbench-shell {
|
|
||||||
+ min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: repeat(4, minmax(160px, 1fr));
|
|
||||||
- gap: 12px;
|
|
||||||
+ grid-template-rows: 64px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric {
|
|
||||||
- border: 1px solid #d9dde3;
|
|
||||||
- border-radius: 8px;
|
|
||||||
- background: white;
|
|
||||||
- padding: 16px;
|
|
||||||
+.workspace-topbar {
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: 160px minmax(240px, 1fr) auto;
|
|
||||||
+ align-items: center;
|
|
||||||
+ gap: 16px;
|
|
||||||
+ border-bottom: 1px solid var(--border);
|
|
||||||
+ background: var(--panel);
|
|
||||||
+ padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric span {
|
|
||||||
- display: block;
|
|
||||||
- color: #5f6673;
|
|
||||||
- font-size: 14px;
|
|
||||||
+.workbench-body {
|
|
||||||
+ min-height: 0;
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.dashboard-metric strong {
|
|
||||||
- display: block;
|
|
||||||
- margin-top: 8px;
|
|
||||||
- font-size: 28px;
|
|
||||||
+.project-rail,
|
|
||||||
+.channel-sidebar,
|
|
||||||
+.object-inspector {
|
|
||||||
+ border-right: 1px solid var(--border);
|
|
||||||
+ background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.inbox-review {
|
|
||||||
+.project-rail {
|
|
||||||
display: grid;
|
|
||||||
- gap: 12px;
|
|
||||||
+ align-content: start;
|
|
||||||
+ gap: 10px;
|
|
||||||
+ padding: 12px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.project-rail button {
|
|
||||||
+ min-width: 48px;
|
|
||||||
+ min-height: 48px;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: 999px;
|
|
||||||
+ background: var(--panel-muted);
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.project-rail button.active,
|
|
||||||
+.channel-group button.active {
|
|
||||||
+ border-color: var(--primary);
|
|
||||||
+ color: var(--primary);
|
|
||||||
+ background: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.inbox-review h2 {
|
|
||||||
- margin: 0;
|
|
||||||
- font-size: 18px;
|
|
||||||
+.channel-sidebar,
|
|
||||||
+.object-inspector,
|
|
||||||
+.channel-stage {
|
|
||||||
+ padding: 16px;
|
|
||||||
+ overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestions {
|
|
||||||
+.channel-group,
|
|
||||||
+.recent-sessions,
|
|
||||||
+.task-list,
|
|
||||||
+.record-list {
|
|
||||||
display: grid;
|
|
||||||
- gap: 10px;
|
|
||||||
+ gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion {
|
|
||||||
+.channel-group button,
|
|
||||||
+.recent-sessions button,
|
|
||||||
+.task-card,
|
|
||||||
+.record-row,
|
|
||||||
+.cron-row {
|
|
||||||
+ width: 100%;
|
|
||||||
+ border: 1px solid var(--border);
|
|
||||||
+ border-radius: var(--radius);
|
|
||||||
+ background: var(--panel);
|
|
||||||
+ padding: 10px 12px;
|
|
||||||
+}
|
|
||||||
+
|
|
||||||
+.task-card,
|
|
||||||
+.record-row,
|
|
||||||
+.cron-row {
|
|
||||||
display: grid;
|
|
||||||
- grid-template-columns: 20px 1fr;
|
|
||||||
- gap: 10px;
|
|
||||||
+ grid-template-columns: auto 1fr auto;
|
|
||||||
+ gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
- border: 1px solid #d9dde3;
|
|
||||||
- border-radius: 8px;
|
|
||||||
- background: white;
|
|
||||||
- padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion-body {
|
|
||||||
- display: grid;
|
|
||||||
- gap: 4px;
|
|
||||||
+.task-card.completed {
|
|
||||||
+ color: var(--muted);
|
|
||||||
+ background: var(--panel-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestion-body small {
|
|
||||||
- color: #5f6673;
|
|
||||||
+.inspector-tabs {
|
|
||||||
+ display: grid;
|
|
||||||
+ grid-template-columns: repeat(3, 1fr);
|
|
||||||
+ gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
-.suggestions button {
|
|
||||||
- justify-self: start;
|
|
||||||
- border: 1px solid #1f2937;
|
|
||||||
- border-radius: 6px;
|
|
||||||
- background: #1f2937;
|
|
||||||
- color: white;
|
|
||||||
- padding: 9px 12px;
|
|
||||||
- cursor: pointer;
|
|
||||||
+.inspector-tabs button[aria-selected='true'] {
|
|
||||||
+ border-color: var(--primary);
|
|
||||||
+ color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
-@media (max-width: 760px) {
|
|
||||||
- .shell {
|
|
||||||
+@media (max-width: 920px) {
|
|
||||||
+ .workspace-topbar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
+ height: auto;
|
|
||||||
+ padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
- .sidebar {
|
|
||||||
- border-right: 0;
|
|
||||||
- border-bottom: 1px solid #d9dde3;
|
|
||||||
+ .workbench-shell {
|
|
||||||
+ grid-template-rows: auto 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
- .dashboard-grid {
|
|
||||||
- grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
+ .workbench-body {
|
|
||||||
+ grid-template-columns: 1fr;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ .project-rail,
|
|
||||||
+ .channel-sidebar,
|
|
||||||
+ .object-inspector {
|
|
||||||
+ border-right: 0;
|
|
||||||
+ border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
## Task 6: Playwright Smoke Flow
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `apps/web/e2e/project-workbench.spec.ts`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: login form labels from Task 1.
|
|
||||||
- Consumes: project/channel labels from Tasks 2-4.
|
|
||||||
- Produces: smoke coverage for login, dynamic project switching, channel template switching, custom URL channel.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace Playwright smoke tests**
|
|
||||||
|
|
||||||
Update `apps/web/e2e/project-workbench.spec.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
test('login and project channel workbench flow', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
|
||||||
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
|
||||||
await page.getByLabel('Email or username').fill('david@example.com');
|
|
||||||
await page.getByLabel('Password').fill('secret');
|
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByLabel('Project list')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Work Plan 8' }).click();
|
|
||||||
await expect(page.getByText('Tasks')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Project A2' }).click();
|
|
||||||
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
await expect(page.getByText('Ops Dashboard')).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
|
|
||||||
await expect(page.getByText('Open external channel')).toBeVisible();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run Playwright test**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run full web verification**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent\apps\web
|
|
||||||
npm test -- --run
|
|
||||||
npm run build
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-Location D:\work\senlinai\agent
|
|
||||||
git add apps\web\e2e\project-workbench.spec.ts
|
|
||||||
git commit -m "test: cover project channel workbench flow"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
Spec coverage:
|
|
||||||
|
|
||||||
- Login page with server IP/domain, account, password, status, and errors: Task 1.
|
|
||||||
- Dynamic project rail and project-scoped channel refresh: Tasks 2 and 3.
|
|
||||||
- System channels with different page templates: Task 4.
|
|
||||||
- Message flow similar to email: Task 4.
|
|
||||||
- Work plan Todo card style with complete/incomplete state: Task 4.
|
|
||||||
- AI sessions with session list and details: Task 4.
|
|
||||||
- Notes and sources file/attachment management style: Task 4.
|
|
||||||
- Cron plan/reminder management without autonomous Agent execution: Task 4 and Global Constraints.
|
|
||||||
- Custom channel with title, icon, URL: Tasks 2 and 4.
|
|
||||||
- Right inspector with Discussion, Properties, More: Task 4.
|
|
||||||
- shadcn/ui-inspired visual system without React/shadcn dependency: Task 5.
|
|
||||||
- Playwright workflow coverage: Task 6.
|
|
||||||
|
|
||||||
Completeness scan:
|
|
||||||
|
|
||||||
- The plan avoids unresolved markers, deferred-work wording, and vague validation instructions.
|
|
||||||
- Each code-writing step includes concrete file content or concrete implementation shape with exact paths.
|
|
||||||
|
|
||||||
Type consistency:
|
|
||||||
|
|
||||||
- `ChannelType`, `WorkbenchChannel`, `ProjectWorkspace`, and `InspectorItem` are introduced in Task 2 and consumed consistently in Tasks 3 and 4.
|
|
||||||
- `onInspect(item: InspectorItem): void` is the only inspector selection interface.
|
|
||||||
- `onLogin({ apiBase, account })` is the only login completion interface.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Task 6 Report: Playwright Smoke Flow
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Completed and committed the scoped Playwright smoke-flow update.
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
Replaced the legacy workbench shell and inbox smoke tests with one end-to-end test, `login and project channel workbench flow`. The flow covers:
|
|
||||||
|
|
||||||
- Login using the server address, account, and password fields.
|
|
||||||
- Initial Project A1 selection and visibility of the Message Flow 36 channel.
|
|
||||||
- Switching to the Work Plan 8 channel and confirming its task template and inspector action.
|
|
||||||
- Switching projects to Project A2 and confirming its active state and channel refresh.
|
|
||||||
- Opening the Ops Dashboard custom URL channel and confirming its external-channel state.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
All verification commands completed successfully from `apps/web`:
|
|
||||||
|
|
||||||
| Command | Result |
|
|
||||||
| --- | --- |
|
|
||||||
| `npx playwright test` | 1 passed |
|
|
||||||
| `npm test -- --run` | 6 files, 13 tests passed |
|
|
||||||
| `npm run build` | Passed |
|
|
||||||
| `npx playwright test` (final verification) | 1 passed |
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- `apps/web/e2e/project-workbench.spec.ts`: replaced the previous smoke cases with the required login, project-switching, channel-template, and custom URL-channel flow.
|
|
||||||
- `.superpowers/sdd/task-6-report.md`: this implementation report.
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Used the exact labels, button names, values, and assertions supplied in the Task 6 brief.
|
|
||||||
- Confirmed the smoke flow checks both the default active project and the dynamically refreshed Project A2 channel list.
|
|
||||||
- Confirmed channel coverage reaches both the Work Plan template and the custom external URL channel state.
|
|
||||||
- Kept application code and dependencies unchanged; no React or shadcn/ui dependency was added.
|
|
||||||
- Confirmed `git diff --check` exits successfully and the code diff is scoped to the requested Playwright spec.
|
|
||||||
|
|
||||||
## Concerns
|
|
||||||
|
|
||||||
None. Vite reports that no Svelte config file is present and uses its default configuration during tests and build; this is an existing informational message and did not affect verification.
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
`test: cover project channel workbench flow`
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Review package Task 6
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
6341a6b test: cover project channel workbench flow
|
|
||||||
|
|
||||||
## Stat
|
|
||||||
apps/web/e2e/project-workbench.spec.ts | 31 ++++++++++++++++++++-----------
|
|
||||||
1 file changed, 20 insertions(+), 11 deletions(-)
|
|
||||||
|
|
||||||
## Diff
|
|
||||||
diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts
|
|
||||||
index 8a2358a..b1fee8b 100644
|
|
||||||
--- a/apps/web/e2e/project-workbench.spec.ts
|
|
||||||
+++ b/apps/web/e2e/project-workbench.spec.ts
|
|
||||||
@@ -1,17 +1,26 @@
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
-test('project workbench shell renders', async ({ page }) => {
|
|
||||||
+test('login and project channel workbench flow', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
- await expect(page.getByText('项目工作台')).toBeVisible();
|
|
||||||
- await expect(page.getByLabel('服务器登录')).toBeVisible();
|
|
||||||
- await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
|
|
||||||
- await expect(page.getByLabel('项目总览')).toBeVisible();
|
|
||||||
-});
|
|
||||||
|
|
||||||
-test('inbox suggestions require explicit confirmation', async ({ page }) => {
|
|
||||||
- await page.goto('/');
|
|
||||||
- await page.getByLabel('选择 跟进报价').check();
|
|
||||||
- await page.getByRole('button', { name: '创建选中项' }).click();
|
|
||||||
+ await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
|
||||||
+ await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
|
||||||
+ await page.getByLabel('Email or username').fill('david@example.com');
|
|
||||||
+ await page.getByLabel('Password').fill('secret');
|
|
||||||
+ await page.getByRole('button', { name: 'Log in' }).click();
|
|
||||||
+
|
|
||||||
+ await expect(page.getByLabel('Project list')).toBeVisible();
|
|
||||||
+ await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
|
|
||||||
+
|
|
||||||
+ await page.getByRole('button', { name: 'Work Plan 8' }).click();
|
|
||||||
+ await expect(page.getByText('Tasks')).toBeVisible();
|
|
||||||
+ await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
|
|
||||||
+
|
|
||||||
+ await page.getByRole('button', { name: 'Project A2' }).click();
|
|
||||||
+ await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
+ await expect(page.getByText('Ops Dashboard')).toBeVisible();
|
|
||||||
|
|
||||||
- await expect(page.getByText('已选择 1 项')).toBeVisible();
|
|
||||||
+ await page.getByRole('button', { name: 'Ops Dashboard' }).click();
|
|
||||||
+ await expect(page.getByText('Open external channel')).toBeVisible();
|
|
||||||
});
|
|
||||||
12
README.md
12
README.md
@@ -151,7 +151,8 @@ Vite 默认地址为 `http://localhost:5173`。登录页的服务器地址默认
|
|||||||
```powershell
|
```powershell
|
||||||
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
||||||
Set-Location (Join-Path $repoRoot 'backend')
|
Set-Location (Join-Path $repoRoot 'backend')
|
||||||
go test ./...
|
go test -count=1 ./...
|
||||||
|
go vet ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Web:
|
Web:
|
||||||
@@ -164,15 +165,16 @@ npm run build
|
|||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
如果设置了指向隔离测试库的 `DATABASE_URL`,再运行 PostgreSQL 集成检查:
|
如果设置了指向隔离测试库的专用 `TEST_DATABASE_URL`,再运行完整 PostgreSQL 集成测试套件:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
||||||
Set-Location (Join-Path $repoRoot 'backend')
|
Set-Location (Join-Path $repoRoot 'backend')
|
||||||
go test -tags integration ./internal/models -run TestPostgresPing -v
|
$env:TEST_DATABASE_URL = '<隔离测试库连接串>'
|
||||||
|
go test -count=1 -tags=integration ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
不得把真实测试数据库凭据写入仓库。当前机器的逐项执行结果与产物路径见 [MVP 验证说明](docs/mvp-verification.md)。
|
不得把真实测试数据库凭据写入仓库。带 `integration` tag 的测试在缺少 `TEST_DATABASE_URL` 时会明确失败,且不会读取运行环境的 `DATABASE_URL`。当前机器的逐项执行结果与产物路径见 [MVP 验证说明](docs/mvp-verification.md)。
|
||||||
|
|
||||||
## 桌面端
|
## 桌面端
|
||||||
|
|
||||||
@@ -226,5 +228,5 @@ npm run bundle
|
|||||||
- AI 分析和会话不会自动创建正式任务、笔记或资料。Inbox 只有在用户确认服务端保存的建议后才创建对象,并保留来源 Inbox ID。
|
- AI 分析和会话不会自动创建正式任务、笔记或资料。Inbox 只有在用户确认服务端保存的建议后才创建对象,并保留来源 Inbox ID。
|
||||||
- 项目标签只在项目内有效。任务移动、标签写入和文件上传都校验项目所有权。
|
- 项目标签只在项目内有效。任务移动、标签写入和文件上传都校验项目所有权。
|
||||||
- 任务分享遵循最小可见范围:被指派人只能看到任务以及被显式分享的关联对象;分享的 note/source 必须与任务属于同一项目。
|
- 任务分享遵循最小可见范围:被指派人只能看到任务以及被显式分享的关联对象;分享的 note/source 必须与任务属于同一项目。
|
||||||
- 文件存储路径只由文件服务构造,上传在写磁盘前校验所有权和请求体大小。
|
- 文件存储路径只由文件服务构造,上传在写磁盘前校验所有权和请求体大小;公开 DTO 仅返回不透明存储键,不暴露物理路径。
|
||||||
- 生产部署必须替换所有开发凭据和密钥、限制 CORS 来源、使用独立数据库与受控存储目录,并通过 HTTPS 暴露服务。
|
- 生产部署必须替换所有开发凭据和密钥、限制 CORS 来源、使用独立数据库与受控存储目录,并通过 HTTPS 暴露服务。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senlinai_workbench"
|
name = "senlinai_workbench"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "SenlinAI project workbench desktop shell"
|
description = "森林AI 项目工作台桌面壳"
|
||||||
authors = ["SenlinAI"]
|
authors = ["森林AI"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "SenlinAI Workbench",
|
"productName": "森林AI",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "ai.senlin.workbench",
|
"identifier": "ai.senlin.workbench",
|
||||||
"build": {
|
"build": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "项目工作台",
|
"title": "森林AI",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 820
|
"height": 820
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
# SenlinAI Acro React
|
# 森林AI Web 客户端
|
||||||
|
|
||||||
SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design.
|
森林AI 当前主 Web 客户端,使用 React、TypeScript、Vite 和 Arco Design,目录为 `apps/web_v1`。
|
||||||
|
|
||||||
This app is the active web client under `apps/web_v1`.
|
开发服务器默认运行在 `http://localhost:5173`,登录页默认连接 `http://localhost:9150` 的 API;两者都可按私有部署环境调整。
|
||||||
|
|
||||||
## Scripts
|
## 开发与验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
|
node --test scripts/api-client.test.mjs scripts/workspace-refresh-gate.test.mjs
|
||||||
|
node scripts/structure-check.mjs
|
||||||
|
node scripts/visual-check.mjs
|
||||||
npm run build
|
npm run build
|
||||||
npm run lint
|
npm run lint
|
||||||
node scripts/visual-check.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Default local preview port: `4174`.
|
视觉检查使用临时 Vite 服务,默认端口为 `4175`;可通过 `VISUAL_CHECK_PORT` 覆盖。`dist/` 与 `test-results/` 都是本地产物,不应提交。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 森林AI MVP 最终验证与视觉审计
|
# 森林AI MVP 最终验证与视觉审计
|
||||||
|
|
||||||
审计日期:2026-07-21
|
审计日期:2026-07-22
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
@@ -19,10 +19,13 @@
|
|||||||
| 1440×1024 | `project-ai-controlled.png` | 会话列表、受控创建入口和“不会自动生成正式对象”说明可见 |
|
| 1440×1024 | `project-ai-controlled.png` | 会话列表、受控创建入口和“不会自动生成正式对象”说明可见 |
|
||||||
| 1440×1024 | `project-inbox-confirmed.png` | 收集内容、分析建议、选择项、确认结果和不可重复写入状态可见 |
|
| 1440×1024 | `project-inbox-confirmed.png` | 收集内容、分析建议、选择项、确认结果和不可重复写入状态可见 |
|
||||||
| 390×844 | `login-react-acro-mobile.png` | 单栏布局,无水平裁切;首屏下方内容可纵向滚动 |
|
| 390×844 | `login-react-acro-mobile.png` | 单栏布局,无水平裁切;首屏下方内容可纵向滚动 |
|
||||||
| 390×844 | `project-react-acro-mobile.png` | 默认关闭导航后主内容可访问;标题正常横排、URL 位于视口内,指标以 2×2 排列 |
|
| 1024×768 | `channel-navigation-tablet-1024.png` | 频道侧栏默认收起,可从项目轨道右侧打开抽屉 |
|
||||||
| 390×844 | `project-navigation-mobile.png` | 项目抽屉、遮罩与关闭路径可见 |
|
| 390×844 | `project-react-acro-mobile.png` | 默认关闭导航后主内容可访问;标题和项目标识位于视口内,指标以 2×2 排列 |
|
||||||
|
| 390×844 | `workbench-mobile-search.png` | 移动端保留可打开、可关闭的全局搜索入口 |
|
||||||
|
| 390×844 | `project-navigation-mobile.png` | 13 个项目可在独立区域滚动,新建项目入口保持可见 |
|
||||||
| 390×844 | `channel-navigation-mobile.png` | 频道抽屉、最近会话与关闭路径可见 |
|
| 390×844 | `channel-navigation-mobile.png` | 频道抽屉、最近会话与关闭路径可见 |
|
||||||
| 390×844 | `project-inbox-confirmed-mobile.png` | Inbox 列表和详情按单列重排,无横向溢出 |
|
| 390×844 | `project-inbox-confirmed-mobile.png` | Inbox 列表和详情按单列重排,无横向溢出 |
|
||||||
|
| 1440×1024 | `project-inbox-empty-capture-analysis.png` | 空 Inbox 从真实收集表单进入刷新与分析流程 |
|
||||||
|
|
||||||
视觉脚本在桌面切换到移动视口后,会等待项目轨道与频道侧栏真正进入隐藏状态再采集项目主内容,避免把 160ms 收起动画的中间帧误当成最终证据。
|
视觉脚本在桌面切换到移动视口后,会等待项目轨道与频道侧栏真正进入隐藏状态再采集项目主内容,避免把 160ms 收起动画的中间帧误当成最终证据。
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@
|
|||||||
- 项目页取消常驻右侧检查器,布局边界变为项目轨道、频道侧栏和主内容三段连续结构;项目强调色集中在横幅和小型标记。
|
- 项目页取消常驻右侧检查器,布局边界变为项目轨道、频道侧栏和主内容三段连续结构;项目强调色集中在横幅和小型标记。
|
||||||
- 任务页从静态示例卡片改为后端工作区数据驱动的标签、计数和空状态。
|
- 任务页从静态示例卡片改为后端工作区数据驱动的标签、计数和空状态。
|
||||||
- 390px 项目页不再是桌面布局左侧切片;主内容默认可见,项目与频道分别通过顶部按钮打开抽屉。
|
- 390px 项目页不再是桌面布局左侧切片;主内容默认可见,项目与频道分别通过顶部按钮打开抽屉。
|
||||||
- 390px 项目概览标题保持正常横排,项目 URL 保持在容器内并可在必要时省略,四项指标使用 2×2 网格并保持标签、数值和说明横向可读。
|
- 390px 项目概览标题保持正常横排,持久化项目标识保持在容器内,四项指标使用 2×2 网格并保持标签、数值和说明横向可读。
|
||||||
- Inbox 和 AI 页面明确保留“分析/受控会话 → 用户确认 → 正式对象”的边界,不把 AI 输出表现为已经自动执行。
|
- Inbox 和 AI 页面明确保留“分析/受控会话 → 用户确认 → 正式对象”的边界,不把 AI 输出表现为已经自动执行。
|
||||||
|
|
||||||
## 核心交互与安全边界
|
## 核心交互与安全边界
|
||||||
@@ -46,7 +49,9 @@
|
|||||||
- 项目/任务/笔记关键词搜索;较慢的旧响应不能覆盖较新的结果。
|
- 项目/任务/笔记关键词搜索;较慢的旧响应不能覆盖较新的结果。
|
||||||
- 被授权的外部任务或笔记只打开受限预览,不加载或切换到无所有权的项目工作区;未知结果类型不伪造项目导航。
|
- 被授权的外部任务或笔记只打开受限预览,不加载或切换到无所有权的项目工作区;未知结果类型不伪造项目导航。
|
||||||
- 项目设置更新包含写入中状态、成功刷新和中文失败提示。
|
- 项目设置更新包含写入中状态、成功刷新和中文失败提示。
|
||||||
|
- 项目设置失败时保留原编辑草稿;截图 `project-mutation-failure-draft-retained.png` 记录该状态。
|
||||||
- Inbox 分析只读取服务端保存的建议;确认只发送勾选的建议 identity。网络结果不确定时冻结选择并复用原请求安全重试,确定性 400、409 冲突和写入后刷新失败分别处理。
|
- Inbox 分析只读取服务端保存的建议;确认只发送勾选的建议 identity。网络结果不确定时冻结选择并复用原请求安全重试,确定性 400、409 冲突和写入后刷新失败分别处理。
|
||||||
|
- 自定义链接频道明确显示只读“暂未开放”,计划任务只展示服务端持久化元数据且不提供伪开关或固定成功结果。
|
||||||
- AI 会话创建只发送标题和项目上下文;同项目乱序 GET、跨项目延迟 POST 和项目切换后的加载失败不会污染当前项目状态。
|
- AI 会话创建只发送标题和项目上下文;同项目乱序 GET、跨项目延迟 POST 和项目切换后的加载失败不会污染当前项目状态。
|
||||||
- 探索数据源、自定义频道/外链、升级支付、窗口停靠和自主 Agent 等范围外能力不呈现为可成功执行的控件。
|
- 探索数据源、自定义频道/外链、升级支付、窗口停靠和自主 Agent 等范围外能力不呈现为可成功执行的控件。
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@
|
|||||||
|
|
||||||
## 剩余限制
|
## 剩余限制
|
||||||
|
|
||||||
- PostgreSQL 测试带 `integration` build tag,未带 tag 时不会被编译。当前 `DATABASE_DSN` 与 `DATABASE_URL` 均未配置,因此本轮未运行 tagged live check;在隔离测试库配置其一后,应运行 `go test -tags=integration ./internal/models -run TestPostgresPing -v`。缺少两个变量时该测试会失败,而不是跳过。
|
- PostgreSQL 行为测试带 `integration` build tag,未带 tag 时不会编译进普通测试。只有设置隔离数据库专用的 `TEST_DATABASE_URL` 后,才运行完整套件 `go test -count=1 -tags=integration ./...`;缺少该变量时 tagged 测试会明确失败,且不会回退读取 `DATABASE_URL`。
|
||||||
- 当前环境未安装 Docker CLI,未执行 Compose 启动、数据库初始化或容器内端到端检查。
|
- 当前环境未安装 Docker CLI,未执行 Compose 启动、数据库初始化或容器内端到端检查。
|
||||||
- Web 构建成功,但主 JavaScript chunk 约 663 kB、主 CSS chunk 约 592 kB,Vite 报告大于 500 kB 的体积警告;这是性能优化项,不阻塞 MVP 正确性验收。
|
- Web 构建成功,但主 JavaScript chunk 约 663 kB、主 CSS chunk 约 592 kB,Vite 报告大于 500 kB 的体积警告;这是性能优化项,不阻塞 MVP 正确性验收。
|
||||||
- 桌面壳构建成功;Windows 链接器输出了导入库创建信息并被 Rust 记录为一条 linker warning,没有构建错误。
|
- 桌面壳构建成功;Windows 链接器输出了导入库创建信息并被 Rust 记录为一条 linker warning,没有构建错误。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 森林AI MVP 验证
|
# 森林AI MVP 验证
|
||||||
|
|
||||||
本文记录 2026-07-21 在 Windows、PowerShell 环境中对当前仓库实际执行的验证。只有获得成功退出码或明确 HTTP 响应的命令列为通过;未满足环境条件的项目单独列出,不视为通过。
|
本文记录 2026-07-22 在 Windows、PowerShell 环境中对当前仓库实际执行的验证。只有获得成功退出码或明确 HTTP 响应的命令列为通过;未满足环境条件的项目单独列出,不视为通过。
|
||||||
|
|
||||||
## 环境基线
|
## 环境基线
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
- Rust `1.97.1`
|
- Rust `1.97.1`
|
||||||
- Cargo `1.97.1`
|
- Cargo `1.97.1`
|
||||||
- Docker CLI:当前机器不可用
|
- Docker CLI:当前机器不可用
|
||||||
- `DATABASE_URL`:当前 shell 未设置
|
- `TEST_DATABASE_URL`:当前 shell 未设置
|
||||||
|
|
||||||
本地 Compose 与 `backend/etc/agent.dev.yaml` 约定 PostgreSQL 监听 `localhost:5432`,API 监听 `localhost:9150`,Vite 默认监听 `localhost:5173`。Compose 和开发配置中的固定数据库凭据只供本地开发,生产环境必须替换。
|
本地 Compose 与 `backend/etc/agent.dev.yaml` 约定 PostgreSQL 监听 `localhost:5432`,API 监听 `localhost:9150`,Vite 默认监听 `localhost:5173`。Compose 和开发配置中的固定数据库凭据只供本地开发,生产环境必须替换。
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
| 桌面端依赖安装 | 通过 | `apps/desktop` 下 `npm ci` 成功。 |
|
| 桌面端依赖安装 | 通过 | `apps/desktop` 下 `npm ci` 成功。 |
|
||||||
| Tauri 可执行文件构建 | 通过 | 冷构建超过单次 240 秒工具等待上限,但已完成 release 编译;同一命令增量重跑 58.3 秒退出 `0`。 |
|
| Tauri 可执行文件构建 | 通过 | 冷构建超过单次 240 秒工具等待上限,但已完成 release 编译;同一命令增量重跑 58.3 秒退出 `0`。 |
|
||||||
| Tauri 安装包 | 未验证 | 本次未运行 `npm run bundle`。 |
|
| Tauri 安装包 | 未验证 | 本次未运行 `npm run bundle`。 |
|
||||||
| PostgreSQL 集成测试 | 未运行 | 当前 shell 未提供 `DATABASE_URL`。 |
|
| PostgreSQL 集成测试 | 未运行 | 当前 shell 未提供隔离数据库专用的 `TEST_DATABASE_URL`。 |
|
||||||
|
|
||||||
## 本地启动检查
|
## 本地启动检查
|
||||||
|
|
||||||
@@ -111,11 +111,11 @@ go test -count=1 ./...
|
|||||||
```powershell
|
```powershell
|
||||||
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
||||||
Set-Location (Join-Path $repoRoot 'backend')
|
Set-Location (Join-Path $repoRoot 'backend')
|
||||||
$env:DATABASE_URL = '<隔离测试库连接串>'
|
$env:TEST_DATABASE_URL = '<隔离测试库连接串>'
|
||||||
go test -tags integration ./internal/models -run TestPostgresPing -v
|
go test -count=1 -tags=integration ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
连接串必须通过安全的本地或 CI secret 注入,不能提交到仓库。本次 `DATABASE_URL` 未设置,因此没有运行,也不列为通过。
|
连接串必须通过安全的本地或 CI secret 注入,不能提交到仓库。Tagged 测试不会读取运行环境的 `DATABASE_URL`;缺少 `TEST_DATABASE_URL` 时会明确失败。本次未设置该变量,因此没有运行实时数据库测试,也不列为通过。
|
||||||
|
|
||||||
### Web
|
### Web
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ npm run lint
|
|||||||
node scripts/structure-check.mjs
|
node scripts/structure-check.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
四条命令本次均退出 `0`。视觉检查覆盖桌面和移动登录页、工作台、项目导航、Inbox 确认、AI 会话受控创建、深色模式、无横向溢出和无未预期 console error。
|
四条命令本次均退出 `0`。视觉检查覆盖桌面和移动登录页、1024px 频道抽屉、390px 搜索与项目抽屉、13 项目滚动、空 Inbox 收集到分析、Inbox 确认、失败草稿保留、AI 会话受控创建、深色模式、无横向溢出和无未预期 console error。
|
||||||
|
|
||||||
`npm run build` 生成:
|
`npm run build` 生成:
|
||||||
|
|
||||||
@@ -145,6 +145,11 @@ node scripts/structure-check.mjs
|
|||||||
- `project-react-acro-dark.png`
|
- `project-react-acro-dark.png`
|
||||||
- `project-navigation-mobile.png`
|
- `project-navigation-mobile.png`
|
||||||
- `channel-navigation-mobile.png`
|
- `channel-navigation-mobile.png`
|
||||||
|
- `channel-navigation-tablet-1024.png`
|
||||||
|
- `workbench-mobile-search.png`
|
||||||
|
- `project-inbox-empty-capture-analysis.png`
|
||||||
|
- `project-mutation-failure-draft-retained.png`
|
||||||
|
- `project-custom-link-unavailable.png`
|
||||||
- `project-inbox-confirmed.png`
|
- `project-inbox-confirmed.png`
|
||||||
- `project-inbox-confirmed-mobile.png`
|
- `project-inbox-confirmed-mobile.png`
|
||||||
- `project-ai-controlled.png`
|
- `project-ai-controlled.png`
|
||||||
@@ -200,7 +205,7 @@ npm run bundle
|
|||||||
2. 使用仅供开发的演示账号登录,并确认生产部署未使用默认凭据。
|
2. 使用仅供开发的演示账号登录,并确认生产部署未使用默认凭据。
|
||||||
3. 创建项目,修改项目设置,再刷新确认数据保留。
|
3. 创建项目,修改项目设置,再刷新确认数据保留。
|
||||||
4. 创建项目内标签和任务,验证任务卡显示真实标签与状态。
|
4. 创建项目内标签和任务,验证任务卡显示真实标签与状态。
|
||||||
5. 上传文件资料,确认返回路径是相对存储路径,且越权项目不能留下文件。
|
5. 上传文件资料,确认公开响应只包含不透明存储键、不泄露物理路径,且越权项目不能留下文件。
|
||||||
6. 创建计划任务元数据,确认界面没有暗示自主 Agent 已执行。
|
6. 创建计划任务元数据,确认界面没有暗示自主 Agent 已执行。
|
||||||
7. 收集一条 Inbox 内容,先分析建议;此时不应出现正式对象。
|
7. 收集一条 Inbox 内容,先分析建议;此时不应出现正式对象。
|
||||||
8. 选择一条或多条服务端建议并确认,验证生成对象保留来源 Inbox ID。
|
8. 选择一条或多条服务端建议并确认,验证生成对象保留来源 Inbox ID。
|
||||||
@@ -218,7 +223,7 @@ npm run bundle
|
|||||||
- AI 输出只形成待确认草稿;用户确认前不创建任务、笔记或资料。
|
- AI 输出只形成待确认草稿;用户确认前不创建任务、笔记或资料。
|
||||||
- 项目标签保持项目内范围;任务分享的 note/source 与任务属于同一项目。
|
- 项目标签保持项目内范围;任务分享的 note/source 与任务属于同一项目。
|
||||||
- 被指派人只看到任务和明确分享的关联对象。
|
- 被指派人只看到任务和明确分享的关联对象。
|
||||||
- PostgreSQL 集成测试只使用显式 `DATABASE_URL` 指向的隔离测试库。
|
- PostgreSQL 集成测试只使用显式 `TEST_DATABASE_URL` 指向的隔离测试库,不读取运行环境的 `DATABASE_URL`。
|
||||||
|
|
||||||
## 文档一致性检查
|
## 文档一致性检查
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user