# Final Review Package ## Base 4fc9271 ## Head 6341a6bd8b5f79a919b31358ff2c0996b07b4f59 ## Commits 6341a6b test: cover project channel workbench flow 7086b8b fix: style active inspector view 092930e style: apply workbench visual system 1a82a8b fix: address workbench inspector review findings 3194cbe feat: add workbench channel templates 95dce81 feat: add project channel workbench shell 6c12d4b feat: add project workspace data model 5b95668 feat: add full workbench login page ## Stat .superpowers/sdd/task-2-report.md | 44 ++++ .superpowers/sdd/task-4-report.md | 67 ++++++ .superpowers/sdd/task-5-report.md | 43 ++++ apps/web/e2e/project-workbench.spec.ts | 31 ++- apps/web/src/app/App.svelte | 77 +------ apps/web/src/features/auth/ServerLogin.svelte | 63 ++++-- apps/web/src/features/auth/ServerLogin.test.ts | 49 +++++ .../src/features/workbench/ChannelContent.svelte | 30 +++ .../src/features/workbench/ChannelContent.test.ts | 39 ++++ .../src/features/workbench/ObjectInspector.svelte | 37 ++++ .../workbench/ProjectChannelSidebar.svelte | 52 +++++ apps/web/src/features/workbench/ProjectRail.svelte | 26 +++ .../src/features/workbench/ProjectWorkbench.svelte | 48 ++++ .../features/workbench/ProjectWorkbench.test.ts | 54 +++++ .../src/features/workbench/WorkspaceTopbar.svelte | 16 ++ .../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 ++ 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 ++++++++ apps/web/src/index.css | 245 ++++++++++++--------- 26 files changed, 1058 insertions(+), 201 deletions(-) ## 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/.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 ` 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/.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/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(); }); diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte index a4c6389..e7170ef 100644 --- a/apps/web/src/app/App.svelte +++ b/apps/web/src/app/App.svelte @@ -1,76 +1,19 @@ <script lang="ts"> - import { onMount } from 'svelte'; - import ProjectDashboard from '../features/projects/ProjectDashboard.svelte'; + import ProjectWorkbench from '../features/workbench/ProjectWorkbench.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} + <ProjectWorkbench {currentUser} /> +{: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(); + }); +}); 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/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..3d29c30 --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte @@ -0,0 +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"> + <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> + </section> + <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 new file mode 100644 index 0000000..f2b6e72 --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts @@ -0,0 +1,54 @@ +import '@testing-library/jest-dom/vitest'; +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(); + 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(); + }); + + 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(); + + 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/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> 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> 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[]; +}; 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); } }