76 KiB
Final Review Package v5
Base
Head
Commits
1fcec3e chore: remove sdd task reports
7b1bbf1 chore: remove last sdd scratch report
884148b fix web review validation and clipboard tests
ec59dee chore: remove final review scratch report
2d82705 fix: resolve final workbench re-review findings
e289b1d chore: remove final sdd scratch artifact
27ff659 fix: resolve final workbench review findings
43d0b9f chore: remove sdd scratch artifact
ba4ca16 fix: resolve workbench final review findings
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
apps/web/e2e/project-workbench.spec.ts | 80 ++++- apps/web/src/app/App.svelte | 77 +--- apps/web/src/features/auth/ServerLogin.svelte | 140 +++++++- apps/web/src/features/auth/ServerLogin.test.ts | 102 ++++++ .../src/features/workbench/ChannelContent.svelte | 32 ++ .../src/features/workbench/ChannelContent.test.ts | 64 ++++ .../src/features/workbench/ObjectInspector.svelte | 67 ++++ .../src/features/workbench/ObjectInspector.test.ts | 73 ++++ .../workbench/ProjectChannelSidebar.svelte | 67 ++++ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++ .../src/features/workbench/WorkspaceTopbar.svelte | 16 + .../workbench/channels/AISessionsChannel.svelte | 27 ++ .../features/workbench/channels/CronChannel.svelte | 19 + .../workbench/channels/CustomLinkChannel.svelte | 34 ++ .../workbench/channels/CustomLinkChannel.test.ts | 70 ++++ .../workbench/channels/InboxChannel.svelte | 22 ++ .../workbench/channels/NotesSourcesChannel.svelte | 19 + .../workbench/channels/OverviewChannel.svelte | 29 ++ .../workbench/channels/TasksChannel.svelte | 36 ++ 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 | 392 +++++++++++++++------ 25 files changed, 1556 insertions(+), 193 deletions(-)
Diff
diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts index 8a2358a..1b67f8f 100644 --- a/apps/web/e2e/project-workbench.spec.ts +++ b/apps/web/e2e/project-workbench.spec.ts @@ -1,17 +1,79 @@ 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();
- 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(); });
-test('inbox suggestions require explicit confirmation', async ({ page }) => { +for (const server of [
- 'https://user:secret@example.com',
- 'http:////example.com',
- 'http//example.com',
- 'https//example.com',
- 'http:/example.com',
- 'https:/example.com',
- 'http://example.com\path',
- 'http://example..com',
- 'http://-bad.com', +]) {
- test(
rejects malformed server address: ${server}, async ({ page }) => { - await page.goto('/');
- await page.getByLabel('Server IP or domain').fill(server);
- 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.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
- await expect(page.getByLabel('Server IP or domain')).toBeVisible();
- }); +}
+test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
- await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/');
-
await page.getByLabel('选择 跟进报价').check();
-
await page.getByRole('button', { name: '创建选中项' }).click();
-
await expect(page.getByText('已选择 1 项')).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();
- const rail = page.getByLabel('Project list');
- const sidebar = page.getByLabel('Project channels');
- const stage = page.getByLabel('Channel content');
- const inspector = page.getByLabel('Object inspector');
- await expect(rail).toHaveCSS('grid-auto-flow', 'column');
- await expect(rail).toHaveCSS('max-height', '76px');
- await expect(inspector).toHaveCSS('max-height', '288px');
- const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
- await expect(workPlan).toBeVisible();
- await expect(workPlan).toBeInViewport();
- await workPlan.click();
- await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
- const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
- expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
- expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248); }); 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 @@
-
-
项目工作台
- <ServerLogin {apiBase} on:serverChange={handleServerChange} />
-
{connectionStatus}
-
-
-
<h2>AI 整理建议</h2> -
<SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} /> -
<p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
-
+{#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..147724d 100644 --- a/apps/web/src/features/auth/ServerLogin.svelte +++ b/apps/web/src/features/auth/ServerLogin.svelte @@ -1,28 +1,134 @@
-<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
- 保存服务器 - +
-
-
-
<p>Private workbench</p> -
<h1 id="login-title">SenlinAI Workbench</h1> -
<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} -
<p class="connection-status" role="status">{connectionStatus}</p> -
<button type="submit">Log in</button> -
<p class="login-note">The server address is remembered on this device.</p>
+ 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..e8a617c --- /dev/null +++ b/apps/web/src/features/auth/ServerLogin.test.ts @@ -0,0 +1,102 @@ +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();
- expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
- });
- 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.each([
- ['localhost', 'http://localhost'],
- ['localhost:8080', 'http://localhost:8080'],
- ['192.168.1.20:3000', 'http://192.168.1.20:3000'],
- ['example.com:8443', 'http://example.com:8443'],
- ['https://example.com', 'https://example.com'],
- ])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
- const onLogin = vi.fn();
- render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
- await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
- 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, 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();
- });
- it.each([
- ['', 'Enter a server address.'],
- ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
- [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
- ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://', 'Enter a valid HTTP or HTTPS server address.'],
- ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://example.com\path', 'Enter a valid HTTP or HTTPS server address.'],
- ['example.com\path', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
- ['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
- ])('rejects an invalid server value: %s', async (server, error) => {
- const onLogin = vi.fn();
- render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
- await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
- 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(screen.getByText(error)).toBeInTheDocument();
- expect(onLogin).not.toHaveBeenCalled();
- }); +}); diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte new file mode 100644 index 0000000..6d18e6a --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.svelte @@ -0,0 +1,32 @@ +<script lang="ts">
- import type { InspectorItem, ProjectWorkspace, WorkTask, 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 tasks: WorkTask[] = workspace.tasks;
- export let channel: WorkbenchChannel;
- export let onInspect: (item: InspectorItem) => void;
- export let onToggleTask: (taskID: string) => 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} {onInspect} onToggleTask={onToggleTask} /> +{: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..e5887db --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.test.ts @@ -0,0 +1,64 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData';
+describe('ChannelContent', () => {
- const workspace = getProjectWorkspace(1);
- it('renders a distinctive heading or control for every channel type', () => {
- const expectedContent = {
-
overview: { role: 'heading', name: 'Overview' }, -
inbox: { role: 'heading', name: 'Inbox' }, -
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' }, -
ai_sessions: { role: 'heading', name: 'AI Sessions' }, -
notes_sources: { role: 'heading', name: 'Notes & Sources' }, -
cron: { role: 'heading', name: 'Cron Plans' }, -
custom_link: { role: 'link', name: 'Open external channel' }, - } as const;
- for (const type of Object.keys(expectedContent) as Array) {
-
const channel = workspace.channels.find((item) => item.type === type); -
if (!channel) throw new Error(`missing ${type}`); -
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); -
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument(); -
unmount(); - }
- });
- 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');
- });
- it('reports task completion changes to its owner', async () => {
- const channel = workspace.channels.find((item) => item.type === 'tasks');
- if (!channel) throw new Error('missing task channel');
- const onToggleTask = vi.fn();
- render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
- const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
- expect(completion).not.toBeChecked();
- await fireEvent.click(completion);
- expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
- }); +}); diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte new file mode 100644 index 0000000..6fbe503 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.svelte @@ -0,0 +1,67 @@ +<script lang="ts">
- import type { InspectorItem } from './types';
- export let item: InspectorItem | null;
- let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
- let copyFeedback = '';
- let copyFailed = false;
- let currentItem: InspectorItem | null = null;
- $: if (item !== currentItem) {
- currentItem = item;
- copyFeedback = '';
- copyFailed = false;
- }
- async function copyLink() {
- const link = new URL(
?item=${encodeURIComponent(item?.title ?? '')}, window.location.href).toString(); - if (!navigator.clipboard?.writeText) {
-
copyFailed = true; -
copyFeedback = 'Clipboard access is unavailable.'; -
return; - }
- try {
-
await navigator.clipboard.writeText(link); -
copyFailed = false; -
copyFeedback = 'Link copied to clipboard.'; - } catch {
-
copyFailed = true; -
copyFeedback = 'Unable to copy link.'; - }
- } +</script>
+
diff --git a/apps/web/src/features/workbench/ObjectInspector.test.ts b/apps/web/src/features/workbench/ObjectInspector.test.ts new file mode 100644 index 0000000..b586131 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.test.ts @@ -0,0 +1,73 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ObjectInspector from './ObjectInspector.svelte';+const item = {
- title: 'Review navigation',
- type: 'Task',
- description: 'Check the project navigation flow.',
- properties: [], +}; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+function setClipboard(writeText: Clipboard['writeText']) {
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText } as Clipboard,
- }); +}
+afterEach(() => {
- cleanup();
- if (originalClipboard) {
- Object.defineProperty(navigator, 'clipboard', originalClipboard);
- } else {
- Reflect.deleteProperty(navigator, 'clipboard');
- } +});
+describe('ObjectInspector', () => {
- async function openMoreActions() {
- await fireEvent.click(screen.getByRole('button', { name: 'More' }));
- }
- it('shows copy success only after clipboard writing resolves', async () => {
- let resolveWrite: (() => void) | undefined;
- const writeText = vi.fn(
-
() => -
new Promise<void>((resolve) => { -
resolveWrite = resolve; -
}), - );
- setClipboard(writeText);
- render(ObjectInspector, { props: { item } });
- await openMoreActions();
- await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
- expect(screen.queryByRole('status')).not.toBeInTheDocument();
- resolveWrite?.();
- await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
- });
- it('reports a copy failure when clipboard writing rejects', async () => {
- setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
- render(ObjectInspector, { props: { item } });
- await openMoreActions();
- await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
- await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
- });
- it('reports unavailable clipboard access without showing success', async () => {
- Reflect.deleteProperty(navigator, 'clipboard');
- render(ObjectInspector, { props: { item } });
- await openMoreActions();
- await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
- expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
- }); +}); diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte new file mode 100644 index 0000000..251682b --- /dev/null +++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte @@ -0,0 +1,67 @@ +<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;
- const channelIcons: Record<WorkbenchChannel['icon'], string> = {
- home: '\u2302',
- mail: '\u2709',
- list: '\u2630',
- sparkles: '\u2726',
- file: '\u25A4',
- clock: '\u25F7',
- link: '\u21AA',
- };
- $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); +</script>
+
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte new file mode 100644 index 0000000..495bffc --- /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>
+
- SA
- {#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} - {/each}
- + +
...tasksByProjectID,
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
+
-
- <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
- <ProjectChannelSidebar
-
project={workspace.project} -
channels={workspace.channels} -
{selectedChannelID} -
tags={workspace.tags} -
recentSessions={workspace.recentSessions} -
onSelectChannel={selectChannel} - />
-
-
<ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
+
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..1fda28b --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts @@ -0,0 +1,113 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { describe, expect, it, vi } 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')).toBeDisabled();
- 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();
- });
- it('clears the inspector when the project or channel changes', async () => {
- render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
- const inspector = screen.getByLabelText('Object inspector');
- 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(screen.getByRole('button', { name: 'Project A2' }));
- expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
- expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
- await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
- expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
- });
- it('preserves task completion when switching channels within a project', async () => {
- render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
- await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
- const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
- await fireEvent.click(task);
- expect(task).toBeChecked();
- await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
- expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
- await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
- expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
- });
- it('provides Copy link feedback and disables unavailable archive actions', async () => {
- const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
- Object.defineProperty(navigator, 'clipboard', {
-
configurable: true, -
value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard, - });
- render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
- const inspector = screen.getByLabelText('Object inspector');
- await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
- await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
- await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
- await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
- await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
- expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
- if (originalClipboard) {
-
Object.defineProperty(navigator, 'clipboard', originalClipboard); - } else {
-
Reflect.deleteProperty(navigator, 'clipboard'); - }
- }); +}); diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte new file mode 100644 index 0000000..33f7564 --- /dev/null +++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte @@ -0,0 +1,16 @@ +<script lang="ts">
- export let account: string; +</script>
+
-
SenlinAI
- Search
-
- ←
- →
- <button aria-label={
Account ${account}} disabled>{account.slice(0, 2).toUpperCase()}
+
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>
+
-
Project intelligence
AI Sessions
-
-
-
{#each sessions as session} -
<button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> -
{/each} - {#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}
+
- 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>
+
-
Scheduled work
Cron Plans
-
- {#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}
+
- import type { WorkbenchChannel } from '../types';
- export let channel: WorkbenchChannel;
- let copyStatus = '';
- let copyFailed = false;
- async function copyUrl() {
- if (!navigator.clipboard?.writeText) {
-
copyFailed = true; -
copyStatus = 'Copy unavailable in this browser.'; -
return; - }
- try {
-
await navigator.clipboard.writeText(channel.url ?? ''); -
copyFailed = false; -
copyStatus = 'URL copied.'; - } catch {
-
copyFailed = true; -
copyStatus = 'Unable to copy URL.'; - }
- } +</script>
+
-
External resource
{channel.title}
-
{channel.url}
- Open external channel
- Copy URL
- {#if copyStatus}
-
{copyStatus}
- {/if} +
+const channel = {
- id: 'project-custom-link',
- projectID: 1,
- type: 'custom_link' as const,
- title: 'Roadmap Board',
- icon: 'link',
- url: 'https://example.com/roadmap',
- sortOrder: 1, +}; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
+function setClipboard(writeText: Clipboard['writeText']) {
- Object.defineProperty(navigator, 'clipboard', {
- configurable: true,
- value: { writeText } as Clipboard,
- }); +}
+afterEach(() => {
- cleanup();
- if (originalClipboard) {
- Object.defineProperty(navigator, 'clipboard', originalClipboard);
- } else {
- Reflect.deleteProperty(navigator, 'clipboard');
- } +});
+describe('CustomLinkChannel', () => {
- it('shows copy success only after clipboard writing resolves', async () => {
- let resolveWrite: (() => void) | undefined;
- const writeText = vi.fn(
-
() => -
new Promise<void>((resolve) => { -
resolveWrite = resolve; -
}), - );
- setClipboard(writeText);
- render(CustomLinkChannel, { props: { channel } });
- await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
- expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
- resolveWrite?.();
- await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
- });
- it('reports a copy failure when clipboard writing rejects', async () => {
- setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
- render(CustomLinkChannel, { props: { channel } });
- await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
- await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
- });
- it('reports unavailable clipboard access without showing URL copied', async () => {
- Reflect.deleteProperty(navigator, 'clipboard');
- render(CustomLinkChannel, { props: { channel } });
- await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
- await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
- expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
- }); +}); 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>
+
-
Message Flow
Inbox
-
- {#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}
+
- 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>
+
-
Project reference
Notes & Sources
-
- {#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}
+
- 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>
+
-
{workspace.project.name}
-
Overview
-
- {#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}
+
- import type { InspectorItem, WorkTask } from '../types';
- export let tasks: WorkTask[];
- export let onInspect: (item: InspectorItem) => void;
- export let onToggleTask: (taskID: string) => 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> + +
-
Work Plan
Tasks
-
- {#each tasks as task}
-
<article class:completed={task.completed} class="task-card"> -
<input -
class="task-check" -
type="checkbox" -
aria-label={`Mark ${task.title} complete`} -
checked={task.completed} -
on:change={() => onToggleTask(task.id)} -
/> -
<div> -
<h2>{task.title}</h2> -
<p>{task.summary}</p> -
<small>{task.owner} / {task.due} / #{task.tag}</small> -
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span> -
</div> -
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> -
</article> - {/each}
+
- 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..ea3f080 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,165 +1,357 @@ :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;
- --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; -} +* { 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, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: 0.55; }
-body {
- margin: 0;
- min-width: 320px; +.login-page { min-height: 100vh;
- display: grid;
- place-items: center;
- padding: 24px;
- background: var(--background); }
-button, -input, -textarea, -select {
- font: inherit; +.login-panel {
- width: min(420px, 100%);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--panel);
- padding: 24px; }
-.shell { +.server-login, +.login-heading, +.channel-page, +.object-inspector, +.channel-sidebar { display: grid;
- grid-template-columns: 280px 1fr;
- min-height: 100vh;
- gap: 12px; }
-.sidebar {
- border-right: 1px solid #d9dde3;
- background: #ffffff;
- padding: 24px; +.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; }
-.sidebar h1 {
- margin: 0 0 24px;
- font-size: 24px; +.connection-status, +.login-note, +.form-error, +.channel-header p, +.channel-page small, +.channel-page > p, +.object-inspector > p {
- margin: 0;
- color: var(--muted);
- font-size: 0.875rem; }
-.workspace { +.form-error { color: var(--danger); } + +.workbench-shell {
- min-height: 100vh; display: grid;
- gap: 24px;
- padding: 24px;
- grid-template-rows: 64px 1fr; }
-.connection-status, -.selection-status {
- margin: 12px 0 0;
- color: #5f6673;
- font-size: 13px; +.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; }
-.server-login { +.brand-mark { font-weight: 700; } +.search-box { display: grid; gap: 4px; min-width: 0; } +.search-box span { color: var(--muted); font-size: 0.75rem; } +.topbar-actions { display: flex; gap: 8px; } +.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); } + +.workbench-body {
- min-height: 0; display: grid;
- gap: 8px;
- grid-template-columns: 76px 280px minmax(0, 1fr) 320px; }
-.server-login label {
- font-size: 13px;
- color: #4b5563; +.project-rail, +.channel-sidebar, +.object-inspector {
- border-right: 1px solid var(--border);
- background: var(--panel); }
-.server-login input {
- width: 100%;
- border: 1px solid #c7ccd4;
- border-radius: 6px;
- padding: 9px 10px; +.project-rail {
- display: grid;
- align-content: start;
- gap: 10px;
- padding: 12px; }
-.server-login button {
- border: 1px solid #1f2937;
- border-radius: 6px;
- background: #1f2937;
- color: white;
- padding: 9px 10px;
- cursor: pointer; +.project-rail button {
- min-width: 48px;
- min-height: 48px;
- border: 1px solid var(--border);
- border-radius: 999px;
- background: var(--panel-muted); }
-.dashboard-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(160px, 1fr));
- gap: 12px; +.project-rail button.active, +.channel-group button.active {
- border-color: var(--primary);
- color: var(--primary);
- background: #eff6ff; }
-.dashboard-metric {
- border: 1px solid #d9dde3;
- border-radius: 8px;
- background: white; +.project-rail small { color: var(--muted); font-size: 0.6875rem; }
+.channel-sidebar, +.object-inspector, +.channel-stage { padding: 16px;
- overflow: auto; }
-.dashboard-metric span {
- display: block;
- color: #5f6673;
- font-size: 14px; +.channel-sidebar {
- align-content: start;
- justify-items: start; }
-.dashboard-metric strong {
- display: block;
- margin-top: 8px;
- font-size: 28px; +.channel-sidebar > header, +.channel-sidebar > .channel-group, +.channel-sidebar > .recent-sessions {
- width: 100%; }
-.inbox-review { +.channel-group, +.task-list, +.record-list, +.message-list, +.file-list, +.cron-list { display: grid;
- gap: 12px;
- gap: 8px; }
-.inbox-review h2 {
- margin: 0;
- font-size: 18px; +.channel-group button, +.task-card, +.record-row, +.cron-row, +.message-row, +.file-row, +.conversation-summary, +.metric-card {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--panel);
- padding: 10px 12px; }
-.suggestions { +.task-card, +.record-row, +.cron-row, +.message-row, +.file-row { display: grid;
- gap: 10px;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: 12px;
- align-items: start; }
-.suggestion {
- display: grid;
- grid-template-columns: 20px 1fr;
- gap: 10px;
- align-items: start;
- border: 1px solid #d9dde3;
- border-radius: 8px;
- background: white;
- padding: 12px; +.task-card.completed {
- color: var(--muted);
- background: var(--panel-muted); }
-.suggestion-body { +.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); } +.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; } + +.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.channel-sidebar header p, +.channel-sidebar h2, +.channel-sidebar h3 { margin: 0; } +.channel-sidebar h2 { font-size: 1rem; } +.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; } +.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); } +.tag-row, +.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; } +.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; } +.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; } +.channel-group small { color: var(--muted); } +.channel-icon { color: var(--muted); text-align: center; } +.recent-sessions { display: grid; gap: 8px; } +.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); } +.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; } +.recent-sessions small { color: var(--muted); font-size: 0.6875rem; } + +.channel-stage { background: var(--background); } +.channel-page { align-content: start; } +.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; } +.channel-header h1, +.channel-page h2 { margin: 0; } +.channel-header h1 { font-size: 1.25rem; } +.channel-page h2 { font-size: 0.9375rem; } +.channel-page h2 + p { margin: 4px 0; } +.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; } +.metric-card { display: grid; gap: 4px; text-align: left; } +.metric-card strong { font-size: 1.5rem; } +.metric-card span { color: var(--muted); font-size: 0.8125rem; } +.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; } +.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; } +.message-row p, +.cron-row p, +.conversation-summary p { margin: 4px 0; } +.message-row button, +.file-row button, +.task-card > button, +.cron-row button, +.conversation-summary button, +.copy-url-button, +.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; } +.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; } +.session-list { display: grid; align-content: start; gap: 6px; } +.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; } +.session-list button.active { border-color: var(--primary); background: #eff6ff; } +.session-list small { color: var(--muted); } +.conversation-summary { align-content: start; } +.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; } +.copy-url-button { width: max-content; } +.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; } +.copy-feedback.copy-error { color: var(--danger); } + +.object-inspector {
- align-content: start;
- grid-auto-rows: min-content; +}
+.inspector-tabs { display: grid;
- grid-template-columns: repeat(3, 1fr);
- align-content: start;
- grid-auto-rows: min-content; gap: 4px; }
-.suggestion-body small {
- color: #5f6673; +.inspector-tabs button[aria-pressed='true'] {
- border-color: var(--primary);
- color: var(--primary); }
-.suggestions button {
- justify-self: start;
- border: 1px solid #1f2937;
- border-radius: 6px;
- background: #1f2937;
- color: white;
- padding: 9px 12px;
- cursor: pointer; -} +@media (max-width: 920px) {
- .workspace-topbar {
- grid-template-columns: minmax(0, 1fr) auto;
- height: auto;
- padding: 12px;
- }
- .search-box { grid-column: 1 / -1; grid-row: 2; }
- .topbar-actions { grid-column: 2; grid-row: 1; }
-@media (max-width: 760px) {
- .shell {
- .workbench-shell {
- grid-template-rows: auto 1fr;
- }
- .workbench-body { grid-template-columns: 1fr;
- align-content: start; }
- .sidebar {
- .project-rail,
- .channel-sidebar,
- .object-inspector { border-right: 0;
- border-bottom: 1px solid #d9dde3;
- border-bottom: 1px solid var(--border); }
- .dashboard-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- .project-rail {
- grid-template-columns: none;
- grid-auto-flow: column;
- grid-auto-columns: 48px;
- align-items: center;
- max-height: 76px;
- overflow-x: auto;
- overflow-y: hidden;
- padding: 10px 12px;
- }
- .project-rail button { min-width: 48px; min-height: 48px; }
- .channel-sidebar {
- align-content: start;
- max-height: 220px;
- padding: 12px; }
- .channel-group,
- .tag-row,
- .recent-sessions ul {
- grid-auto-flow: column;
- grid-auto-columns: minmax(132px, max-content);
- grid-template-columns: none;
- flex-wrap: nowrap;
- overflow-x: auto;
- padding-bottom: 2px;
- }
- .channel-group button { min-width: 160px; }
- .channel-group {
- grid-auto-flow: row;
- grid-auto-columns: auto;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- overflow: visible;
- }
- .channel-group button {
- min-width: 0;
- min-height: 48px;
- grid-template-columns: 18px minmax(0, 1fr);
- padding: 8px;
- }
- .channel-group button > span:last-of-type {
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .channel-group small { display: none; }
- .recent-sessions li { min-width: 160px; }
- .channel-stage { max-height: 36vh; overflow: auto; }
- .object-inspector { max-height: 288px; overflow: auto; }
- .session-layout { grid-template-columns: 1fr; } }