1374 lines
43 KiB
Markdown
1374 lines
43 KiB
Markdown
|
|
# Discord Style Project Workbench UI Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Build the confirmed shadcn/ui-inspired Svelte workbench UI with a full login page, dynamic project rail, project-scoped channel sidebar, channel-specific content pages, and a right-side object inspector.
|
||
|
|
|
||
|
|
**Architecture:** Keep the implementation frontend-focused under `apps/web/src`. `App.svelte` owns authentication state and switches between login and workbench. `ProjectWorkbench.svelte` owns selected project, selected channel, selected object, and passes focused data into small channel components. Mock project/channel data lives in a local typed data module so later backend API wiring can replace it without reshaping every component.
|
||
|
|
|
||
|
|
**Tech Stack:** Svelte 5, TypeScript, Vite, Vitest, Testing Library for Svelte, Playwright, local CSS. Visual style follows root `design.md`; do not add React or shadcn/ui runtime dependencies.
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- Frontend only uses Svelte; do not add React dependencies or React components.
|
||
|
|
- UI can visually reference shadcn/ui, but must not add shadcn/ui as a dependency.
|
||
|
|
- Web app is desktop-first while remaining usable on mobile browsers.
|
||
|
|
- Feature code lives under `apps/web/src/features`.
|
||
|
|
- Login page must allow users to enter server IP address or domain.
|
||
|
|
- Browser-style workspace state is separate from editor drafts and AI input drafts.
|
||
|
|
- MVP uses Markdown for note editing.
|
||
|
|
- Project tags remain project-scoped; do not introduce global tags.
|
||
|
|
- AI may not create official objects without user confirmation.
|
||
|
|
- Cron plan UI is plan/reminder management only; do not implement autonomous Agent execution.
|
||
|
|
- Custom channels are URL shortcuts with title, icon, URL, and sort order.
|
||
|
|
- Web verification runs from `apps/web`: `npm test -- --run`, `npm run build`, and Playwright when main workflow changes.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
Create or modify these files:
|
||
|
|
|
||
|
|
- Modify: `apps/web/src/app/App.svelte`
|
||
|
|
Owns app-level login/workbench switching, persisted API base URL, and mock login session.
|
||
|
|
- Modify: `apps/web/src/features/auth/ServerLogin.svelte`
|
||
|
|
Becomes a complete login page with server address, account, password, submit, and error/connection state.
|
||
|
|
- Create: `apps/web/src/features/workbench/types.ts`
|
||
|
|
Defines `WorkbenchProject`, `WorkbenchChannel`, `ChannelType`, channel object types, inspector model, and mock item interfaces.
|
||
|
|
- Create: `apps/web/src/features/workbench/mockData.ts`
|
||
|
|
Provides typed dynamic projects, project-specific system channels, custom URL channels, tags, recent sessions, inbox messages, tasks, notes/sources, cron plans, and AI sessions.
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
||
|
|
Main logged-in shell with topbar, project rail, channel sidebar, main channel content, and inspector.
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectRail.svelte`
|
||
|
|
Dynamic project list with active project state and create-project action.
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
|
||
|
|
Project-scoped channel list, tags, custom channels, recent sessions, and counts.
|
||
|
|
- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
|
||
|
|
Logo, global search, navigation actions, account action.
|
||
|
|
- Create: `apps/web/src/features/workbench/ChannelContent.svelte`
|
||
|
|
Dispatches selected channel to the matching channel component.
|
||
|
|
- Create: `apps/web/src/features/workbench/ObjectInspector.svelte`
|
||
|
|
Right panel with Discussion, Properties, and More tabs.
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/OverviewChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/InboxChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/TasksChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/AISessionsChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/CronChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
||
|
|
- Modify: `apps/web/e2e/project-workbench.spec.ts`
|
||
|
|
- Modify: `apps/web/src/index.css`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Login Page And App-Level Session
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `apps/web/src/features/auth/ServerLogin.svelte`
|
||
|
|
- Modify: `apps/web/src/app/App.svelte`
|
||
|
|
- Test: `apps/web/src/features/auth/ServerLogin.test.ts`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `ServerLogin` Svelte component event `login: { apiBase: string; account: string }`
|
||
|
|
- Produces: app state fields `apiBase: string`, `currentUser: { account: string } | null`
|
||
|
|
- Consumes: `localStorage['apiBase']`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing login component tests**
|
||
|
|
|
||
|
|
Create `apps/web/src/features/auth/ServerLogin.test.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import '@testing-library/jest-dom/vitest';
|
||
|
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||
|
|
import { describe, expect, it, vi } from 'vitest';
|
||
|
|
import ServerLogin from './ServerLogin.svelte';
|
||
|
|
|
||
|
|
describe('ServerLogin', () => {
|
||
|
|
it('renders server, account, and password fields', () => {
|
||
|
|
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||
|
|
|
||
|
|
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||
|
|
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('normalizes server address and emits login details', async () => {
|
||
|
|
const onLogin = vi.fn();
|
||
|
|
render(ServerLogin, {
|
||
|
|
props: {
|
||
|
|
apiBase: 'localhost:8080',
|
||
|
|
onLogin,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
|
||
|
|
target: { value: '10.0.0.12:8080' },
|
||
|
|
});
|
||
|
|
await fireEvent.input(screen.getByLabelText('Email or username'), {
|
||
|
|
target: { value: 'david@example.com' },
|
||
|
|
});
|
||
|
|
await fireEvent.input(screen.getByLabelText('Password'), {
|
||
|
|
target: { value: 'secret' },
|
||
|
|
});
|
||
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||
|
|
|
||
|
|
expect(onLogin).toHaveBeenCalledWith({
|
||
|
|
apiBase: 'http://10.0.0.12:8080',
|
||
|
|
account: 'david@example.com',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows an error when account or password is missing', async () => {
|
||
|
|
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||
|
|
|
||
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||
|
|
|
||
|
|
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the new test to verify it fails**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run src/features/auth/ServerLogin.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL because `ServerLogin` does not render account/password fields and does not expose `onLogin`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Replace `ServerLogin.svelte` with the complete login page**
|
||
|
|
|
||
|
|
Implement `apps/web/src/features/auth/ServerLogin.svelte`:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<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"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Workbench Types And Project-Scoped Mock Data
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `apps/web/src/features/workbench/types.ts`
|
||
|
|
- Create: `apps/web/src/features/workbench/mockData.ts`
|
||
|
|
- Test: `apps/web/src/features/workbench/mockData.test.ts`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `type ChannelType = 'overview' | 'inbox' | 'tasks' | 'ai_sessions' | 'notes_sources' | 'cron' | 'custom_link'`
|
||
|
|
- Produces: `getProjectWorkspace(projectID: number): ProjectWorkspace`
|
||
|
|
- Produces: `workbenchProjects: WorkbenchProject[]`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing mock data tests**
|
||
|
|
|
||
|
|
Create `apps/web/src/features/workbench/mockData.test.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { describe, expect, it } from 'vitest';
|
||
|
|
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||
|
|
|
||
|
|
describe('workbench mock data', () => {
|
||
|
|
it('provides dynamic projects', () => {
|
||
|
|
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
|
||
|
|
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('loads project-specific channels and counts', () => {
|
||
|
|
const first = getProjectWorkspace(workbenchProjects[0].id);
|
||
|
|
const second = getProjectWorkspace(workbenchProjects[1].id);
|
||
|
|
|
||
|
|
expect(first.project.id).not.toBe(second.project.id);
|
||
|
|
expect(first.channels.map((channel) => channel.type)).toEqual([
|
||
|
|
'overview',
|
||
|
|
'inbox',
|
||
|
|
'tasks',
|
||
|
|
'ai_sessions',
|
||
|
|
'notes_sources',
|
||
|
|
'cron',
|
||
|
|
'custom_link',
|
||
|
|
]);
|
||
|
|
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
|
||
|
|
title: expect.any(String),
|
||
|
|
url: expect.stringMatching(/^https?:\/\//),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run src/features/workbench/mockData.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL because the files do not exist.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create `types.ts`**
|
||
|
|
|
||
|
|
Create `apps/web/src/features/workbench/types.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export type ChannelType =
|
||
|
|
| 'overview'
|
||
|
|
| 'inbox'
|
||
|
|
| 'tasks'
|
||
|
|
| 'ai_sessions'
|
||
|
|
| 'notes_sources'
|
||
|
|
| 'cron'
|
||
|
|
| 'custom_link';
|
||
|
|
|
||
|
|
export type WorkbenchProject = {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
description: string;
|
||
|
|
initials: string;
|
||
|
|
unreadCount: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type WorkbenchChannel = {
|
||
|
|
id: string;
|
||
|
|
projectID: number;
|
||
|
|
type: ChannelType;
|
||
|
|
title: string;
|
||
|
|
icon: string;
|
||
|
|
count?: number;
|
||
|
|
url?: string;
|
||
|
|
sortOrder: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type InboxMessage = {
|
||
|
|
id: string;
|
||
|
|
source: string;
|
||
|
|
title: string;
|
||
|
|
summary: string;
|
||
|
|
status: 'open' | 'processed' | 'archived';
|
||
|
|
tag: string;
|
||
|
|
time: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type WorkTask = {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
summary: string;
|
||
|
|
completed: boolean;
|
||
|
|
owner: string;
|
||
|
|
due: string;
|
||
|
|
tag: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type AISessionItem = {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
summary: string;
|
||
|
|
updatedAt: string;
|
||
|
|
references: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
export type NoteSourceItem = {
|
||
|
|
id: string;
|
||
|
|
kind: 'note' | 'file' | 'link';
|
||
|
|
title: string;
|
||
|
|
updatedAt: string;
|
||
|
|
tag: string;
|
||
|
|
source: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type CronPlan = {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
schedule: string;
|
||
|
|
nextRun: string;
|
||
|
|
enabled: boolean;
|
||
|
|
lastResult: string;
|
||
|
|
owner: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type InspectorItem = {
|
||
|
|
title: string;
|
||
|
|
type: string;
|
||
|
|
description: string;
|
||
|
|
properties: Array<{ label: string; value: string }>;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type ProjectWorkspace = {
|
||
|
|
project: WorkbenchProject;
|
||
|
|
channels: WorkbenchChannel[];
|
||
|
|
tags: string[];
|
||
|
|
recentSessions: AISessionItem[];
|
||
|
|
inbox: InboxMessage[];
|
||
|
|
tasks: WorkTask[];
|
||
|
|
aiSessions: AISessionItem[];
|
||
|
|
notesSources: NoteSourceItem[];
|
||
|
|
cronPlans: CronPlan[];
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create `mockData.ts`**
|
||
|
|
|
||
|
|
Create `apps/web/src/features/workbench/mockData.ts` with two project workspaces:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
|
||
|
|
|
||
|
|
export const workbenchProjects: WorkbenchProject[] = [
|
||
|
|
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
|
||
|
|
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
|
||
|
|
];
|
||
|
|
|
||
|
|
function systemChannels(projectID: number): WorkbenchChannel[] {
|
||
|
|
return [
|
||
|
|
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
|
||
|
|
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
|
||
|
|
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
|
||
|
|
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
|
||
|
|
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
|
||
|
|
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
|
||
|
|
{
|
||
|
|
id: `${projectID}-custom-roadmap`,
|
||
|
|
projectID,
|
||
|
|
type: 'custom_link',
|
||
|
|
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
|
||
|
|
icon: 'link',
|
||
|
|
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
|
||
|
|
sortOrder: 7,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
|
||
|
|
project,
|
||
|
|
channels: systemChannels(project.id),
|
||
|
|
tags: ['all', 'tag1', 'UI', 'knowledge'],
|
||
|
|
recentSessions: [
|
||
|
|
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
|
||
|
|
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
|
||
|
|
],
|
||
|
|
inbox: [
|
||
|
|
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
|
||
|
|
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
|
||
|
|
],
|
||
|
|
tasks: [
|
||
|
|
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
|
||
|
|
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
|
||
|
|
],
|
||
|
|
aiSessions: [
|
||
|
|
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
|
||
|
|
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
|
||
|
|
],
|
||
|
|
notesSources: [
|
||
|
|
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
|
||
|
|
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
|
||
|
|
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
|
||
|
|
],
|
||
|
|
cronPlans: [
|
||
|
|
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
|
||
|
|
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
|
||
|
|
],
|
||
|
|
}));
|
||
|
|
|
||
|
|
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
|
||
|
|
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run test**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run src/features/workbench/mockData.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent
|
||
|
|
git add apps\web\src\features\workbench\types.ts apps\web\src\features\workbench\mockData.ts apps\web\src\features\workbench\mockData.test.ts
|
||
|
|
git commit -m "feat: add project workspace data model"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectRail.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
|
||
|
|
- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
|
||
|
|
- Modify: `apps/web/src/app/App.svelte`
|
||
|
|
- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `workbenchProjects: WorkbenchProject[]`
|
||
|
|
- Consumes: `getProjectWorkspace(projectID: number): ProjectWorkspace`
|
||
|
|
- Produces: `ProjectWorkbench` prop `currentUser: { account: string }`
|
||
|
|
- Produces: selected project and selected channel rendered with accessible names.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing workbench shell test**
|
||
|
|
|
||
|
|
Create `apps/web/src/features/workbench/ProjectWorkbench.test.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import '@testing-library/jest-dom/vitest';
|
||
|
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||
|
|
import { describe, expect, it } from 'vitest';
|
||
|
|
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
||
|
|
|
||
|
|
describe('ProjectWorkbench', () => {
|
||
|
|
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
||
|
|
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||
|
|
|
||
|
|
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||
|
|
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||
|
|
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||
|
|
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refreshes channels when switching projects', async () => {
|
||
|
|
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||
|
|
|
||
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||
|
|
|
||
|
|
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||
|
|
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: FAIL because workbench components do not exist.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create shell components**
|
||
|
|
|
||
|
|
Implement `ProjectRail.svelte`, `ProjectChannelSidebar.svelte`, and `WorkspaceTopbar.svelte` with explicit props and accessible buttons:
|
||
|
|
|
||
|
|
```svelte
|
||
|
|
<!-- 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"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: shadcn/ui-Inspired Visual System In CSS
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `apps/web/src/index.css`
|
||
|
|
- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: class names introduced in Tasks 1-4.
|
||
|
|
- Produces: stable desktop layout, responsive mobile layout, accessible focus states.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add a test assertion for active state semantics**
|
||
|
|
|
||
|
|
Extend `ProjectWorkbench.test.ts` with:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
it('marks active channel with aria-pressed', async () => {
|
||
|
|
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||
|
|
|
||
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||
|
|
|
||
|
|
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS before visual CSS work; this protects state semantics while styling changes.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Replace `index.css` with workbench visual system**
|
||
|
|
|
||
|
|
Implement CSS tokens and component classes:
|
||
|
|
|
||
|
|
```css
|
||
|
|
:root {
|
||
|
|
color: #18181b;
|
||
|
|
background: #f4f4f5;
|
||
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
|
|
line-height: 1.5;
|
||
|
|
--background: #f4f4f5;
|
||
|
|
--panel: #ffffff;
|
||
|
|
--panel-muted: #fafafa;
|
||
|
|
--border: #d4d4d8;
|
||
|
|
--text: #18181b;
|
||
|
|
--muted: #71717a;
|
||
|
|
--primary: #0f7ae5;
|
||
|
|
--primary-strong: #0969c8;
|
||
|
|
--success: #15803d;
|
||
|
|
--danger: #b91c1c;
|
||
|
|
--radius: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
* { box-sizing: border-box; }
|
||
|
|
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||
|
|
button, input, textarea, select { font: inherit; }
|
||
|
|
button { cursor: pointer; }
|
||
|
|
button:focus-visible, input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||
|
|
|
||
|
|
.login-page {
|
||
|
|
min-height: 100vh;
|
||
|
|
display: grid;
|
||
|
|
place-items: center;
|
||
|
|
padding: 24px;
|
||
|
|
background: var(--background);
|
||
|
|
}
|
||
|
|
|
||
|
|
.login-panel {
|
||
|
|
width: min(420px, 100%);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: var(--radius);
|
||
|
|
background: var(--panel);
|
||
|
|
padding: 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.server-login,
|
||
|
|
.login-heading,
|
||
|
|
.channel-page,
|
||
|
|
.object-inspector,
|
||
|
|
.channel-sidebar {
|
||
|
|
display: grid;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.server-login input,
|
||
|
|
.search-box input {
|
||
|
|
width: 100%;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 6px;
|
||
|
|
padding: 9px 10px;
|
||
|
|
background: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.server-login button,
|
||
|
|
.primary-action {
|
||
|
|
border: 1px solid var(--primary);
|
||
|
|
border-radius: 6px;
|
||
|
|
background: var(--primary);
|
||
|
|
color: white;
|
||
|
|
padding: 9px 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workbench-shell {
|
||
|
|
min-height: 100vh;
|
||
|
|
display: grid;
|
||
|
|
grid-template-rows: 64px 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workspace-topbar {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 160px minmax(240px, 1fr) auto;
|
||
|
|
align-items: center;
|
||
|
|
gap: 16px;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
background: var(--panel);
|
||
|
|
padding: 0 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workbench-body {
|
||
|
|
min-height: 0;
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.project-rail,
|
||
|
|
.channel-sidebar,
|
||
|
|
.object-inspector {
|
||
|
|
border-right: 1px solid var(--border);
|
||
|
|
background: var(--panel);
|
||
|
|
}
|
||
|
|
|
||
|
|
.project-rail {
|
||
|
|
display: grid;
|
||
|
|
align-content: start;
|
||
|
|
gap: 10px;
|
||
|
|
padding: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.project-rail button {
|
||
|
|
min-width: 48px;
|
||
|
|
min-height: 48px;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 999px;
|
||
|
|
background: var(--panel-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.project-rail button.active,
|
||
|
|
.channel-group button.active {
|
||
|
|
border-color: var(--primary);
|
||
|
|
color: var(--primary);
|
||
|
|
background: #eff6ff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.channel-sidebar,
|
||
|
|
.object-inspector,
|
||
|
|
.channel-stage {
|
||
|
|
padding: 16px;
|
||
|
|
overflow: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.channel-group,
|
||
|
|
.recent-sessions,
|
||
|
|
.task-list,
|
||
|
|
.record-list {
|
||
|
|
display: grid;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.channel-group button,
|
||
|
|
.recent-sessions button,
|
||
|
|
.task-card,
|
||
|
|
.record-row,
|
||
|
|
.cron-row {
|
||
|
|
width: 100%;
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: var(--radius);
|
||
|
|
background: var(--panel);
|
||
|
|
padding: 10px 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-card,
|
||
|
|
.record-row,
|
||
|
|
.cron-row {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: auto 1fr auto;
|
||
|
|
gap: 12px;
|
||
|
|
align-items: start;
|
||
|
|
}
|
||
|
|
|
||
|
|
.task-card.completed {
|
||
|
|
color: var(--muted);
|
||
|
|
background: var(--panel-muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector-tabs {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(3, 1fr);
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.inspector-tabs button[aria-selected='true'] {
|
||
|
|
border-color: var(--primary);
|
||
|
|
color: var(--primary);
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 920px) {
|
||
|
|
.workspace-topbar {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
height: auto;
|
||
|
|
padding: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workbench-shell {
|
||
|
|
grid-template-rows: auto 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.workbench-body {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.project-rail,
|
||
|
|
.channel-sidebar,
|
||
|
|
.object-inspector {
|
||
|
|
border-right: 0;
|
||
|
|
border-bottom: 1px solid var(--border);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run component tests and build**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run
|
||
|
|
npm run build
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: both PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent
|
||
|
|
git add apps\web\src\index.css apps\web\src\features\workbench\ProjectWorkbench.test.ts
|
||
|
|
git commit -m "style: apply workbench visual system"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: Playwright Smoke Flow
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `apps/web/e2e/project-workbench.spec.ts`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: login form labels from Task 1.
|
||
|
|
- Consumes: project/channel labels from Tasks 2-4.
|
||
|
|
- Produces: smoke coverage for login, dynamic project switching, channel template switching, custom URL channel.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Replace Playwright smoke tests**
|
||
|
|
|
||
|
|
Update `apps/web/e2e/project-workbench.spec.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { expect, test } from '@playwright/test';
|
||
|
|
|
||
|
|
test('login and project channel workbench flow', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
||
|
|
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
||
|
|
await page.getByLabel('Email or username').fill('david@example.com');
|
||
|
|
await page.getByLabel('Password').fill('secret');
|
||
|
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||
|
|
|
||
|
|
await expect(page.getByLabel('Project list')).toBeVisible();
|
||
|
|
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||
|
|
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Work Plan 8' }).click();
|
||
|
|
await expect(page.getByText('Tasks')).toBeVisible();
|
||
|
|
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Project A2' }).click();
|
||
|
|
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||
|
|
await expect(page.getByText('Ops Dashboard')).toBeVisible();
|
||
|
|
|
||
|
|
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
|
||
|
|
await expect(page.getByText('Open external channel')).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run Playwright test**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npx playwright test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run full web verification**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent\apps\web
|
||
|
|
npm test -- --run
|
||
|
|
npm run build
|
||
|
|
npx playwright test
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: all PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
Run:
|
||
|
|
|
||
|
|
```powershell
|
||
|
|
Set-Location D:\work\senlinai\agent
|
||
|
|
git add apps\web\e2e\project-workbench.spec.ts
|
||
|
|
git commit -m "test: cover project channel workbench flow"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Self-Review
|
||
|
|
|
||
|
|
Spec coverage:
|
||
|
|
|
||
|
|
- Login page with server IP/domain, account, password, status, and errors: Task 1.
|
||
|
|
- Dynamic project rail and project-scoped channel refresh: Tasks 2 and 3.
|
||
|
|
- System channels with different page templates: Task 4.
|
||
|
|
- Message flow similar to email: Task 4.
|
||
|
|
- Work plan Todo card style with complete/incomplete state: Task 4.
|
||
|
|
- AI sessions with session list and details: Task 4.
|
||
|
|
- Notes and sources file/attachment management style: Task 4.
|
||
|
|
- Cron plan/reminder management without autonomous Agent execution: Task 4 and Global Constraints.
|
||
|
|
- Custom channel with title, icon, URL: Tasks 2 and 4.
|
||
|
|
- Right inspector with Discussion, Properties, More: Task 4.
|
||
|
|
- shadcn/ui-inspired visual system without React/shadcn dependency: Task 5.
|
||
|
|
- Playwright workflow coverage: Task 6.
|
||
|
|
|
||
|
|
Completeness scan:
|
||
|
|
|
||
|
|
- The plan avoids unresolved markers, deferred-work wording, and vague validation instructions.
|
||
|
|
- Each code-writing step includes concrete file content or concrete implementation shape with exact paths.
|
||
|
|
|
||
|
|
Type consistency:
|
||
|
|
|
||
|
|
- `ChannelType`, `WorkbenchChannel`, `ProjectWorkspace`, and `InspectorItem` are introduced in Task 2 and consumed consistently in Tasks 3 and 4.
|
||
|
|
- `onInspect(item: InspectorItem): void` is the only inspector selection interface.
|
||
|
|
- `onLogin({ apiBase, account })` is the only login completion interface.
|