# 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 @@
 <script lang="ts">
-  import { onMount } from 'svelte';
-  import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
+  import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
   import ServerLogin from '../features/auth/ServerLogin.svelte';
-  import SuggestionList from '../features/inbox/SuggestionList.svelte';
-  import type { Suggestion } from '../features/inbox/types';
-  import { createApi, type ProjectDashboardSummary } from '../lib/api';
-
-  const emptyDashboard: ProjectDashboardSummary = {
-    project_id: 1,
-    pending_inbox_count: 0,
-    open_task_count: 0,
-    recent_note_count: 0,
-    recent_session_count: 0,
-  };
 
   let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
-  let dashboard = emptyDashboard;
-  let connectionStatus = '未连接';
-  let selectedSuggestionCount = 0;
-
-  const sampleSuggestions: Suggestion[] = [
-    {
-      kind: 'task',
-      title: '跟进报价',
-      body: '从项目 inbox 确认后创建任务，并保留来源记录。',
-    },
-    {
-      kind: 'note',
-      title: '客户背景',
-      body: '把对话中的背景信息沉淀成项目笔记。',
-    },
-  ];
+  let currentUser: { account: string } | null = null;
 
-  onMount(() => {
-    void loadDashboard();
-  });
-
-  function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
-    apiBase = event.detail.apiBase;
+  function handleLogin(detail: { apiBase: string; account: string }) {
+    apiBase = detail.apiBase;
     localStorage.setItem('apiBase', apiBase);
-    void loadDashboard();
-  }
-
-  async function loadDashboard() {
-    connectionStatus = '连接中';
-    try {
-      dashboard = await createApi(apiBase).getProjectDashboard(1);
-      connectionStatus = '已连接';
-    } catch {
-      dashboard = emptyDashboard;
-      connectionStatus = '无法连接服务器';
-    }
-  }
-
-  function handleConfirm(selected: Suggestion[]) {
-    selectedSuggestionCount = selected.length;
+    currentUser = { account: detail.account };
   }
 </script>
 
-<main class="shell">
-  <aside class="sidebar">
-    <h1>项目工作台</h1>
-    <ServerLogin {apiBase} on:serverChange={handleServerChange} />
-    <p class="connection-status" aria-live="polite">{connectionStatus}</p>
-  </aside>
-
-  <section class="workspace">
-    <ProjectDashboard summary={dashboard} />
-    <section class="inbox-review" aria-label="项目 inbox">
-      <h2>AI 整理建议</h2>
-      <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
-      <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
-    </section>
-  </section>
-</main>
+{#if currentUser}
+  <ProjectWorkbench {currentUser} />
+{:else}
+  <ServerLogin {apiBase} onLogin={handleLogin} />
+{/if}
diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
index 8e80261..147724d 100644
--- a/apps/web/src/features/auth/ServerLogin.svelte
+++ b/apps/web/src/features/auth/ServerLogin.svelte
@@ -1,28 +1,134 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
-
   export let apiBase: string;
+  export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
 
-  const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
   let server = apiBase;
+  let account = '';
+  let password = '';
+  let error = '';
+  let connectionStatus = 'Not connected. Enter a server address to continue.';
 
-  function saveServer() {
-    const normalized = normalizeServer(server);
-    server = normalized;
-    dispatch('serverChange', { apiBase: normalized });
+  function normalizeServer(value: string): { apiBase?: string; error?: string } {
+    if (!value.trim()) {
+      return { error: 'Enter a server address.' };
+    }
+
+    if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
+      return { error: 'Enter a valid HTTP or HTTPS server address.' };
+    }
+
+    const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`;
+    if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) {
+      return { error: 'Enter a valid HTTP or HTTPS server address.' };
+    }
+
+    try {
+      const parsed = new URL(candidate);
+      if (
+        !parsed.hostname ||
+        parsed.username ||
+        parsed.password ||
+        !['http:', 'https:'].includes(parsed.protocol) ||
+        !isValidHostname(parsed.hostname)
+      ) {
+        return { error: 'Enter a valid HTTP or HTTPS server address.' };
+      }
+      return { apiBase: candidate };
+    } catch {
+      return { error: 'Enter a valid HTTP or HTTPS server address.' };
+    }
   }
 
-  function normalizeServer(value: string) {
-    const trimmed = value.trim();
-    if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
-      return trimmed;
+  function hasWhitespace(value: string): boolean {
+    if (/\s/u.test(value)) return true;
+
+    try {
+      return /\s/u.test(decodeURIComponent(value));
+    } catch {
+      return true;
     }
-    return `http://${trimmed}`;
+  }
+
+  function hasUnsupportedProtocol(value: string): boolean {
+    const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i);
+    if (explicitProtocol) {
+      return !['http', 'https'].includes(explicitProtocol[1].toLowerCase());
+    }
+
+    return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
+  }
+
+  function hasMalformedHttpProtocol(value: string): boolean {
+    return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
+  }
+
+  function hasCredentials(value: string): boolean {
+    const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
+    return authority.includes('@');
+  }
+
+  function isValidHostname(hostname: string): boolean {
+    if (hostname === 'localhost') return true;
+    if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true;
+    if (/^\d+(?:\.\d+){3}$/.test(hostname)) {
+      return hostname.split('.').every((segment) => Number(segment) <= 255);
+    }
+
+    return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label));
+  }
+
+  function submitLogin() {
+    error = '';
+    const normalized = normalizeServer(server);
+    if (normalized.error) {
+      error = normalized.error;
+      connectionStatus = 'Check the server address before signing in.';
+      return;
+    }
+
+    if (!account.trim() || !password.trim()) {
+      error = 'Enter an account and password.';
+      return;
+    }
+
+    server = normalized.apiBase ?? server;
+    connectionStatus = 'Connection settings are ready.';
+    onLogin({ apiBase: server, account: account.trim() });
   }
 </script>
 
-<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
-  <label for="server-address">服务器 IP 或域名</label>
-  <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
-  <button type="submit">保存服务器</button>
-</form>
+<main class="login-page">
+  <section class="login-panel" aria-labelledby="login-title">
+    <div class="login-brand" aria-hidden="true">SA</div>
+    <div class="login-heading">
+      <p>Private workbench</p>
+      <h1 id="login-title">SenlinAI Workbench</h1>
+    </div>
+
+    <form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
+      <label for="server-address">Server IP or domain</label>
+      <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
+
+      <label for="login-account">Email or username</label>
+      <input id="login-account" name="account" bind:value={account} autocomplete="username" />
+
+      <label for="login-password">Password</label>
+      <input
+        id="login-password"
+        name="password"
+        type="password"
+        bind:value={password}
+        autocomplete="current-password"
+      />
+
+      {#if error}
+        <p class="form-error" aria-live="polite">{error}</p>
+      {/if}
+
+      <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>
+    </form>
+  </section>
+</main>
diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
new file mode 100644
index 0000000..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<keyof typeof expectedContent>) {
+      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>
+
+<aside class="object-inspector" aria-label="Object inspector">
+  <div class="inspector-tabs">
+    <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
+    <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
+    <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
+  </div>
+
+  {#if item}
+    <h2>{item.title}</h2>
+    <p>{item.description}</p>
+    {#if activeTab === 'discussion'}
+      <p>No comments yet. Add project discussion here later.</p>
+    {:else if activeTab === 'properties'}
+      <dl>
+        {#each item.properties as property}
+          <div>
+            <dt>{property.label}</dt>
+            <dd>{property.value}</dd>
+          </div>
+        {/each}
+      </dl>
+    {:else}
+      <button type="button" on:click={copyLink}>Copy link</button>
+      {#if copyFeedback}
+        <p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p>
+      {/if}
+      <button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button>
+    {/if}
+  {:else}
+    <h2>Inspector</h2>
+    <p>Select an item to inspect discussion, properties, and actions.</p>
+  {/if}
+</aside>
diff --git a/apps/web/src/features/workbench/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>
+
+<aside class="channel-sidebar" aria-label="Project channels">
+  <header>
+    <div>
+      <p>Current project</p>
+      <h2>{project.name}</h2>
+    </div>
+    <button aria-label="Project settings" disabled>&#9881;</button>
+  </header>
+
+  <ul class="tag-row" aria-label="Project tags">
+    {#each tags as tag}
+      <li>#{tag}</li>
+    {/each}
+  </ul>
+
+  <section class="channel-group">
+    {#each sortedChannels as channel}
+      <button
+        class:active={channel.id === selectedChannelID}
+        aria-pressed={channel.id === selectedChannelID}
+        aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
+        on:click={() => onSelectChannel(channel.id)}
+      >
+        <span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
+        <span>{channel.title}</span>
+        {#if channel.count !== undefined}
+          <small>{channel.count}</small>
+        {/if}
+      </button>
+    {/each}
+  </section>
+
+  <section class="recent-sessions" aria-label="Recent sessions">
+    <h3>Recent sessions</h3>
+    <ul>
+      {#each recentSessions as session}
+        <li>
+          <span>{session.title}</span>
+          <small>{session.updatedAt}</small>
+        </li>
+      {/each}
+    </ul>
+  </section>
+</aside>
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
new file mode 100644
index 0000000..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>
+
+<nav class="project-rail" aria-label="Project list">
+  <button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
+  {#each projects as project}
+    <button
+      class:active={project.id === selectedProjectID}
+      aria-pressed={project.id === selectedProjectID}
+      aria-label={project.name}
+      title={project.name}
+      on:click={() => onSelectProject(project.id)}
+    >
+      <span>{project.initials}</span>
+      {#if project.unreadCount}
+        <small>{project.unreadCount}</small>
+      {/if}
+    </button>
+  {/each}
+  <button class="add-project" aria-label="Create project" disabled>+</button>
+</nav>
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
new file mode 100644
index 0000000..f95fa73
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+  import { getProjectWorkspace, workbenchProjects } from './mockData';
+  import ChannelContent from './ChannelContent.svelte';
+  import ObjectInspector from './ObjectInspector.svelte';
+  import ProjectRail from './ProjectRail.svelte';
+  import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
+  import WorkspaceTopbar from './WorkspaceTopbar.svelte';
+  import type { InspectorItem, WorkTask } from './types';
+
+  export let currentUser: { account: string };
+
+  let selectedProjectID = workbenchProjects[0].id;
+  let selectedItem: InspectorItem | null = null;
+  let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
+    workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]),
+  );
+  $: workspace = getProjectWorkspace(selectedProjectID);
+  $: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks;
+  $: selectedChannelID = workspace.channels[0].id;
+  $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
+
+  function selectProject(projectID: number) {
+    selectedItem = null;
+    selectedProjectID = projectID;
+  }
+
+  function selectChannel(channelID: string) {
+    selectedItem = null;
+    selectedChannelID = channelID;
+  }
+
+  function inspectItem(item: InspectorItem) {
+    selectedItem = item;
+  }
+
+  function toggleTask(taskID: string) {
+    tasksByProjectID = {
+      ...tasksByProjectID,
+      [selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
+    };
+  }
+</script>
+
+<main class="workbench-shell">
+  <WorkspaceTopbar account={currentUser.account} />
+  <div class="workbench-body">
+    <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
+    <ProjectChannelSidebar
+      project={workspace.project}
+      channels={workspace.channels}
+      {selectedChannelID}
+      tags={workspace.tags}
+      recentSessions={workspace.recentSessions}
+      onSelectChannel={selectChannel}
+    />
+    <section class="channel-stage" aria-label="Channel content">
+      <ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
+    </section>
+    <ObjectInspector item={selectedItem} />
+  </div>
+</main>
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
new file mode 100644
index 0000000..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>
+
+<header class="workspace-topbar">
+  <div class="brand-mark">SenlinAI</div>
+  <label class="search-box">
+    <span>Search</span>
+    <input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled />
+  </label>
+  <div class="topbar-actions">
+    <button aria-label="Back" disabled>&#8592;</button>
+    <button aria-label="Forward" disabled>&#8594;</button>
+    <button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
+  </div>
+</header>
diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
new file mode 100644
index 0000000..3b52383
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+  import type { AISessionItem, InspectorItem } from '../types';
+
+  export let sessions: AISessionItem[];
+  export let onInspect: (item: InspectorItem) => void;
+  let selectedSessionID = sessions[0]?.id;
+
+  $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
+
+  function inspectSession(session: AISessionItem) {
+    onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
+  }
+</script>
+
+<section class="channel-page ai-sessions-channel">
+  <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
+  <div class="session-layout">
+    <div class="session-list">
+      {#each sessions as session}
+        <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
+      {/each}
+    </div>
+    {#if selectedSession}
+      <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
+    {/if}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
new file mode 100644
index 0000000..7a6f2c3
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+  import type { CronPlan, InspectorItem } from '../types';
+
+  export let plans: CronPlan[];
+  export let onInspect: (item: InspectorItem) => void;
+
+  function inspectPlan(plan: CronPlan) {
+    onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
+  }
+</script>
+
+<section class="channel-page cron-channel">
+  <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
+  <div class="cron-list">
+    {#each plans as plan}
+      <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
+    {/each}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
new file mode 100644
index 0000000..57a3ff2
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+  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>
+
+<section class="channel-page custom-link-channel">
+  <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
+  <p>{channel.url}</p>
+  <a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
+  <button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
+  {#if copyStatus}
+    <p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p>
+  {/if}
+</section>
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<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>
+
+<section class="channel-page inbox-channel">
+  <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
+  <div class="message-list">
+    {#each messages as message}
+      <article class="message-row">
+        <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
+        <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
+      </article>
+    {/each}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
new file mode 100644
index 0000000..256ef8b
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+  import type { InspectorItem, NoteSourceItem } from '../types';
+
+  export let items: NoteSourceItem[];
+  export let onInspect: (item: InspectorItem) => void;
+
+  function inspectItem(item: NoteSourceItem) {
+    onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
+  }
+</script>
+
+<section class="channel-page notes-sources-channel">
+  <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
+  <div class="file-list">
+    {#each items as item}
+      <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
+    {/each}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
new file mode 100644
index 0000000..6d478cf
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+  import type { InspectorItem, ProjectWorkspace } from '../types';
+
+  export let workspace: ProjectWorkspace;
+  export let onInspect: (item: InspectorItem) => void;
+
+  const metrics = (workspace: ProjectWorkspace) => [
+    { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
+    { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
+    { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
+    { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
+    { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
+  ];
+</script>
+
+<section class="channel-page overview-channel">
+  <header class="channel-header">
+    <p>{workspace.project.name}</p>
+    <h1>Overview</h1>
+  </header>
+  <div class="metric-list">
+    {#each metrics(workspace) as metric}
+      <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
+        <strong>{metric.value}</strong>
+        <span>{metric.label}</span>
+      </button>
+    {/each}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
new file mode 100644
index 0000000..8639b22
--- /dev/null
+++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+  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>
+
+<section class="channel-page tasks-channel">
+  <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
+  <div class="task-list">
+    {#each tasks as task}
+      <article class:completed={task.completed} class="task-card">
+        <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}
+  </div>
+</section>
diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts
new file mode 100644
index 0000000..fdbf451
--- /dev/null
+++ b/apps/web/src/features/workbench/mockData.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { getProjectWorkspace, workbenchProjects } from './mockData';
+
+describe('workbench mock data', () => {
+  it('provides dynamic projects', () => {
+    expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
+    expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
+  });
+
+  it('loads project-specific channels and counts', () => {
+    const first = getProjectWorkspace(workbenchProjects[0].id);
+    const second = getProjectWorkspace(workbenchProjects[1].id);
+
+    expect(first.project.id).not.toBe(second.project.id);
+    expect(first.channels.map((channel) => channel.type)).toEqual([
+      'overview',
+      'inbox',
+      'tasks',
+      'ai_sessions',
+      'notes_sources',
+      'cron',
+      'custom_link',
+    ]);
+    expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
+      title: expect.any(String),
+      url: expect.stringMatching(/^https?:\/\//),
+    });
+  });
+});
diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts
new file mode 100644
index 0000000..90a25c2
--- /dev/null
+++ b/apps/web/src/features/workbench/mockData.ts
@@ -0,0 +1,61 @@
+import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
+
+export const workbenchProjects: WorkbenchProject[] = [
+  { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
+  { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
+];
+
+function systemChannels(projectID: number): WorkbenchChannel[] {
+  return [
+    { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
+    { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
+    { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
+    { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
+    { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
+    { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
+    {
+      id: `${projectID}-custom-roadmap`,
+      projectID,
+      type: 'custom_link',
+      title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
+      icon: 'link',
+      url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
+      sortOrder: 7,
+    },
+  ];
+}
+
+const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
+  project,
+  channels: systemChannels(project.id),
+  tags: ['all', 'tag1', 'UI', 'knowledge'],
+  recentSessions: [
+    { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
+    { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
+  ],
+  inbox: [
+    { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
+    { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
+  ],
+  tasks: [
+    { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
+    { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
+  ],
+  aiSessions: [
+    { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
+    { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
+  ],
+  notesSources: [
+    { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
+    { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
+    { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
+  ],
+  cronPlans: [
+    { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
+    { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
+  ],
+}));
+
+export function getProjectWorkspace(projectID: number): ProjectWorkspace {
+  return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
+}
diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts
new file mode 100644
index 0000000..98ae0f9
--- /dev/null
+++ b/apps/web/src/features/workbench/types.ts
@@ -0,0 +1,93 @@
+export type ChannelType =
+  | 'overview'
+  | 'inbox'
+  | 'tasks'
+  | 'ai_sessions'
+  | 'notes_sources'
+  | 'cron'
+  | 'custom_link';
+
+export type WorkbenchProject = {
+  id: number;
+  name: string;
+  description: string;
+  initials: string;
+  unreadCount: number;
+};
+
+export type WorkbenchChannel = {
+  id: string;
+  projectID: number;
+  type: ChannelType;
+  title: string;
+  icon: string;
+  count?: number;
+  url?: string;
+  sortOrder: number;
+};
+
+export type InboxMessage = {
+  id: string;
+  source: string;
+  title: string;
+  summary: string;
+  status: 'open' | 'processed' | 'archived';
+  tag: string;
+  time: string;
+};
+
+export type WorkTask = {
+  id: string;
+  title: string;
+  summary: string;
+  completed: boolean;
+  owner: string;
+  due: string;
+  tag: string;
+};
+
+export type AISessionItem = {
+  id: string;
+  title: string;
+  summary: string;
+  updatedAt: string;
+  references: string[];
+};
+
+export type NoteSourceItem = {
+  id: string;
+  kind: 'note' | 'file' | 'link';
+  title: string;
+  updatedAt: string;
+  tag: string;
+  source: string;
+};
+
+export type CronPlan = {
+  id: string;
+  title: string;
+  schedule: string;
+  nextRun: string;
+  enabled: boolean;
+  lastResult: string;
+  owner: string;
+};
+
+export type InspectorItem = {
+  title: string;
+  type: string;
+  description: string;
+  properties: Array<{ label: string; value: string }>;
+};
+
+export type ProjectWorkspace = {
+  project: WorkbenchProject;
+  channels: WorkbenchChannel[];
+  tags: string[];
+  recentSessions: AISessionItem[];
+  inbox: InboxMessage[];
+  tasks: WorkTask[];
+  aiSessions: AISessionItem[];
+  notesSources: NoteSourceItem[];
+  cronPlans: CronPlan[];
+};
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 7a1e945..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; }
 }
