# Final Review Package v5
## Base
4fc9271
## Head
1fcec3e0b5f09ef238a0596344f8d478ee1e55c4
## 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 @@
-
-
-
-
-
-
- AI 整理建议
-
- 已选择 {selectedSuggestionCount} 项
-
-
-
+{#if currentUser}
+
+{:else}
+
+{/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 @@
-
+
+
+ SA
+
+
Private workbench
+
SenlinAI Workbench
+
+
+
+
+
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 @@
+
+
+{#if channel.type === 'overview'}
+
+{:else if channel.type === 'inbox'}
+
+{:else if channel.type === 'tasks'}
+
+{:else if channel.type === 'ai_sessions'}
+
+{:else if channel.type === 'notes_sources'}
+
+{:else if channel.type === 'cron'}
+
+{:else}
+
+{/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 @@
+
+
+
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((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 @@
+
+
+
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 @@
+
+
+
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
new file mode 100644
index 0000000..f95fa73
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
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 @@
+
+
+
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 @@
+
+
+
+
+
+
+ {#each sessions as session}
+
+ {/each}
+
+ {#if selectedSession}
+
{selectedSession.title}
{selectedSession.summary}
References: {selectedSession.references.join(', ')}
+ {/if}
+
+
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 @@
+
+
+
+
+
+ {#each plans as plan}
+
{plan.title}
{plan.schedule} · {plan.nextRun}
{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}
+ {/each}
+
+
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..57a3ff2
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {channel.url}
+ Open external channel
+
+ {#if copyStatus}
+ {copyStatus}
+ {/if}
+
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
new file mode 100644
index 0000000..e968295
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts
@@ -0,0 +1,70 @@
+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 CustomLinkChannel from './CustomLinkChannel.svelte';
+
+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((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 @@
+
+
+
+
+
+ {#each messages as message}
+
+ {message.source} · {message.time}{message.title}
{message.summary}
{message.status} · #{message.tag}
+
+
+ {/each}
+
+
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 @@
+
+
+
+
+
+ {#each items as item}
+
{item.kind}{item.title}
{item.source} · {item.updatedAt} · #{item.tag}
+ {/each}
+
+
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 @@
+
+
+
+
+
+ {#each metrics(workspace) as metric}
+
+ {/each}
+
+
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..8639b22
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
+ {#each tasks as task}
+
+ onToggleTask(task.id)}
+ />
+
+
{task.title}
+
{task.summary}
+
{task.owner} / {task.due} / #{task.tag}
+
{task.completed ? 'Completed' : 'Open'}
+
+
+
+ {/each}
+
+
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..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; }
}