Files
agent/.superpowers/sdd/final-review-package-v3.md
2026-07-20 08:02:20 +08:00

76 KiB

Final Review Package v3

Base

4fc9271

Head

e289b1dbb0

Commits

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

.superpowers/sdd/task-2-report.md | 44 +++ .superpowers/sdd/task-4-report.md | 67 ++++ .superpowers/sdd/task-5-report.md | 43 +++ apps/web/e2e/project-workbench.spec.ts | 68 +++- apps/web/src/app/App.svelte | 77 +--- apps/web/src/features/auth/ServerLogin.svelte | 112 +++++- apps/web/src/features/auth/ServerLogin.test.ts | 72 ++++ .../src/features/workbench/ChannelContent.svelte | 32 ++ .../src/features/workbench/ChannelContent.test.ts | 64 ++++ .../src/features/workbench/ObjectInspector.svelte | 53 +++ .../workbench/ProjectChannelSidebar.svelte | 67 ++++ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ .../features/workbench/ProjectWorkbench.test.ts | 102 ++++++ .../src/features/workbench/WorkspaceTopbar.svelte | 16 + .../workbench/channels/AISessionsChannel.svelte | 27 ++ .../features/workbench/channels/CronChannel.svelte | 19 + .../workbench/channels/CustomLinkChannel.svelte | 25 ++ .../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 | 391 +++++++++++++++------ 26 files changed, 1462 insertions(+), 193 deletions(-)

Diff

diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..48a37cf --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,44 @@ +# Task 2 Report: Workbench Types And Project-Scoped Mock Data + +## What changed + +- Added the typed workbench domain model in apps/web/src/features/workbench/types.ts. +- Added two project records and project-scoped workspace mock data in apps/web/src/features/workbench/mockData.ts. +- Added coverage for dynamic projects, project-specific channels, channel ordering, and custom-link URL shape in apps/web/src/features/workbench/mockData.test.ts. +- Kept the implementation scoped to the requested workbench data and test files. No UI components, React dependencies, or shadcn/ui dependencies were changed. + +## Tests + +- Targeted test: npm test -- --run src/features/workbench/mockData.test.ts -> 1 file passed, 2 tests passed. +- Full Web test suite: npm test -- --run -> 4 files passed, 7 tests passed. +- Web production build: npm run build -> successful. +- Formatting/diff validation: git diff --check -> no issues. + +## TDD evidence + +1. Created mockData.test.ts before production implementation. +2. Ran the targeted test and observed the expected red result: Vitest could not resolve ./mockData because the implementation files did not exist. +3. Added types.ts and mockData.ts using the exact task brief values. +4. Re-ran the targeted test and observed green: 2/2 tests passed. +5. Ran the full Web test suite and build successfully. + +## Files changed + +- apps/web/src/features/workbench/types.ts +- apps/web/src/features/workbench/mockData.ts +- apps/web/src/features/workbench/mockData.test.ts +- .superpowers/sdd/task-2-report.md + +## Self-review + +- ChannelType contains all seven required channel literals in the specified order. +- ProjectWorkspace includes all required project-scoped collections. +- Both projects receive distinct channel IDs, item IDs, and custom-link URLs through the project ID interpolation. +- getProjectWorkspace returns the requested workspace and preserves the brief's fallback to the first workspace for unknown IDs. +- The test verifies at least two projects, numeric IDs, names, distinct workspace IDs, channel order, and custom-link URL format. +- No unrelated tracked files were changed. + +## Concerns + +- Vitest and Vite report the pre-existing warning that no Svelte config was found in apps/web; it did not prevent tests or the build from succeeding. +- The mock data is intentionally static and duplicated across projects via the shared workspace mapper, as required for this MVP task. diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 0000000..b743d8d --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,67 @@ +# Task 4 Report: Channel Content Templates And Object Inspector + +## Status + +Implemented and committed the Task 4 workbench channel templates and object inspector. + +## What Changed + +- Added ChannelContent.svelte, which dispatches ProjectWorkspace data to the selected system or custom channel template. +- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. +- Made all applicable records selectable through an Inspect <title> action that supplies an InspectorItem to the workbench. +- Added ObjectInspector.svelte with Discussion, Properties, and More tabs, including empty-state behavior and object properties. +- Wired the selected channel and selected inspector item into ProjectWorkbench.svelte. +- Added the required channel dispatcher test, including the task-to-inspector callback assertion. +- Kept visual-system CSS untouched for Task 5. + +## TDD Evidence + +1. Added apps/web/src/features/workbench/ChannelContent.test.ts before creating any production channel component. +2. Ran npm test -- --run src/features/workbench/ChannelContent.test.ts from apps/web. +3. Observed the expected red failure: Vite could not resolve ./ChannelContent.svelte because it did not exist. +4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests. +5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings. + +## Tests + +- npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts

    • Passed: 2 test files, 4 tests. +- npm run build
    • Passed: Vite production build completed successfully. +- git diff --check
    • Passed: no whitespace errors.

+## Files Changed + +- Created apps/web/src/features/workbench/ChannelContent.svelte +- Created apps/web/src/features/workbench/ChannelContent.test.ts +- Created apps/web/src/features/workbench/ObjectInspector.svelte +- Created apps/web/src/features/workbench/channels/OverviewChannel.svelte +- Created apps/web/src/features/workbench/channels/InboxChannel.svelte +- Created apps/web/src/features/workbench/channels/TasksChannel.svelte +- Created apps/web/src/features/workbench/channels/AISessionsChannel.svelte +- Created apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte +- Created apps/web/src/features/workbench/channels/CronChannel.svelte +- Created apps/web/src/features/workbench/channels/CustomLinkChannel.svelte +- Modified apps/web/src/features/workbench/ProjectWorkbench.svelte +- Created this report: .superpowers/sdd/task-4-report.md + +## Self-Review + +- Confirmed every WorkbenchChannel type has a dedicated rendered template through the dispatcher. +- Confirmed the custom link uses the required Open external channel text and provides a copy-style action. +- Confirmed task inspection produces the exact task title required by the test. +- Confirmed the inspector remains visible through its existing aria-label and exposes semantic tab state using role="tab" with aria-selected. +- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced. +- Confirmed unrelated existing .superpowers/sdd files were not staged. + +## Concerns + +- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data. +- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work. + +## Review Fixes + +- Replaced the incomplete ARIA tabs pattern in ObjectInspector.svelte with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model. +- Extended ProjectWorkbench.test.ts to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector. +- Focused workbench coverage passed after the changes: 2 test files, 5 tests. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 0000000..d4fc752 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,43 @@ +# Task 5 Report: shadcn/ui-Inspired Visual System In CSS + +## Status + +Implemented and committed the Task 5 workbench visual system. + +## What Changed + +- Replaced apps/web/src/index.css with the specified neutral visual tokens, workbench desktop grid, mobile responsive breakpoint, shared component classes, and focus-visible styles. +- Added the active-channel aria-pressed assertion to ProjectWorkbench.test.ts. + +## Tests + +From apps/web: + +- npm test -- --run src/features/workbench/ProjectWorkbench.test.ts passed: 1 test file, 4 tests. +- npm test -- --run passed: 6 test files, 13 tests. +- npm run build passed: Vite production build completed successfully. + +## TDD Evidence + +The requested active-channel assertion was added before CSS changes and run immediately. It passed because ProjectChannelSidebar already exposed aria-pressed for the selected channel; this task preserves that existing semantic contract while styling the component. + +## Files Changed + +- apps/web/src/index.css +- apps/web/src/features/workbench/ProjectWorkbench.test.ts + +## Self-Review + +- CSS values and selectors follow the Task 5 brief verbatim. +- The layout uses the required 76px/280px/flexible/320px desktop columns and collapses to one column at 920px. +- Keyboard focus is visibly indicated for buttons and inputs. +- No React or shadcn/ui dependencies were added. +- The commit stages only the two task-owned implementation files; pre-existing untracked .superpowers/sdd artifacts remain untouched. + +## Concerns + +The original selector mismatch between aria-selected and the inspector's existing aria-pressed semantics was corrected in the review fix below. + +## Review Fix + +Updated the inspector active-state selector in apps/web/src/index.css from [aria-selected='true'] to [aria-pressed='true'] so it matches ObjectInspector's existing accessible state without changing component semantics. diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts index 8a2358a..33818a3 100644 --- a/apps/web/e2e/project-workbench.spec.ts +++ b/apps/web/e2e/project-workbench.spec.ts @@ -1,17 +1,67 @@ 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('rejects malformed server addresses', async ({ page }) => {

  • await page.goto('/');
  • await page.getByLabel('Server IP or domain').fill('https://user:secret@example.com');
  • 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('inbox suggestions require explicit confirmation', async ({ page }) => { +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>

-

  • 项目工作台

  • <ServerLogin {apiBase} on:serverChange={handleServerChange} />
  • {connectionStatus}

  •  <h2>AI 整理建议</h2>
    
  •  <SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
    
  •  <p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
    

-

+{#if currentUser}

  • <ProjectWorkbench {currentUser} /> +{:else}
  • <ServerLogin {apiBase} onLogin={handleLogin} /> +{/if} diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte index 8e80261..09b367d 100644 --- a/apps/web/src/features/auth/ServerLogin.svelte +++ b/apps/web/src/features/auth/ServerLogin.svelte @@ -1,28 +1,106 @@
<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) || hasUnsupportedProtocol(value)) { + return { error: 'Enter a valid HTTP or HTTPS server address.' }; + } + + const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`; + + try { + const parsed = new URL(candidate); + if (!parsed.hostname || parsed.username || parsed.password || !['http:', 'https:'].includes(parsed.protocol)) { + 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 hasWhitespace(value: string): boolean { + if (/\s/u.test(value)) return true; + + try { + return /\s/u.test(decodeURIComponent(value)); + } catch { + return true; + } + } + + 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 normalizeServer(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; + function submitLogin() { + error = ''; + const normalized = normalizeServer(server); + if (normalized.error) { + error = normalized.error; + connectionStatus = 'Check the server address before signing in.'; + return; } - return `http://${trimmed}`; + + 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}>

  • 保存服务器 - +
  •  <p>Private workbench</p>
    
  •  <h1 id="login-title">SenlinAI Workbench</h1>
    
  •  <label for="server-address">Server IP or domain</label>
    
  •  <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
    
  •  <label for="login-account">Email or username</label>
    
  •  <input id="login-account" name="account" bind:value={account} autocomplete="username" />
    
  •  <label for="login-password">Password</label>
    
  •  <input
    
  •    id="login-password"
    
  •    name="password"
    
  •    type="password"
    
  •    bind:value={password}
    
  •    autocomplete="current-password"
    
  •  />
    
  •  {#if error}
    
  •    <p class="form-error" aria-live="polite">{error}</p>
    
  •  {/if}
    
  •  <p class="connection-status" role="status">{connectionStatus}</p>
    
  •  <button type="submit">Log in</button>
    
  •  <p class="login-note">The server address is remembered on this device.</p>
    

+ diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts new file mode 100644 index 0000000..73e8c4a --- /dev/null +++ b/apps/web/src/features/auth/ServerLogin.test.ts @@ -0,0 +1,72 @@ +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('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.'],
  • ])('rejects an invalid server value: %s', async (server, error) => {
  • const onLogin = vi.fn();
  • render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
  • await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
  • await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
  • await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
  • await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
  • expect(screen.getByText(error)).toBeInTheDocument();
  • expect(onLogin).not.toHaveBeenCalled();
  • }); +}); diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte new file mode 100644 index 0000000..6d18e6a --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.svelte @@ -0,0 +1,32 @@ +<script lang="ts">
  • import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types';
  • import OverviewChannel from './channels/OverviewChannel.svelte';
  • import InboxChannel from './channels/InboxChannel.svelte';
  • import TasksChannel from './channels/TasksChannel.svelte';
  • import AISessionsChannel from './channels/AISessionsChannel.svelte';
  • import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
  • import CronChannel from './channels/CronChannel.svelte';
  • import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
  • export let workspace: ProjectWorkspace;
  • export let tasks: WorkTask[] = workspace.tasks;
  • export let channel: WorkbenchChannel;
  • export let onInspect: (item: InspectorItem) => void;
  • export let onToggleTask: (taskID: string) => void = () => {}; +</script>

+{#if channel.type === 'overview'}

  • <OverviewChannel {workspace} {onInspect} /> +{:else if channel.type === 'inbox'}
  • <InboxChannel messages={workspace.inbox} {onInspect} /> +{:else if channel.type === 'tasks'}
  • <TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} /> +{:else if channel.type === 'ai_sessions'}
  • <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> +{:else if channel.type === 'notes_sources'}
  • <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> +{:else if channel.type === 'cron'}
  • <CronChannel plans={workspace.cronPlans} {onInspect} /> +{:else}
  • <CustomLinkChannel {channel} /> +{/if} diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts new file mode 100644 index 0000000..e5887db --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.test.ts @@ -0,0 +1,64 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData';

+describe('ChannelContent', () => {

  • const workspace = getProjectWorkspace(1);
  • it('renders a distinctive heading or control for every channel type', () => {
  • const expectedContent = {
  •  overview: { role: 'heading', name: 'Overview' },
    
  •  inbox: { role: 'heading', name: 'Inbox' },
    
  •  tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
    
  •  ai_sessions: { role: 'heading', name: 'AI Sessions' },
    
  •  notes_sources: { role: 'heading', name: 'Notes & Sources' },
    
  •  cron: { role: 'heading', name: 'Cron Plans' },
    
  •  custom_link: { role: 'link', name: 'Open external channel' },
    
  • } as const;
  • for (const type of Object.keys(expectedContent) as Array) {
  •  const channel = workspace.channels.find((item) => item.type === type);
    
  •  if (!channel) throw new Error(`missing ${type}`);
    
  •  const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
    
  •  expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
    
  •  unmount();
    
  • }
  • });
  • it('sends selected task details to inspector', async () => {
  • let inspectedTitle = '';
  • const channel = workspace.channels.find((item) => item.type === 'tasks');
  • if (!channel) throw new Error('missing task channel');
  • render(ChannelContent, {
  •  props: {
    
  •    workspace,
    
  •    channel,
    
  •    onInspect: (item) => {
    
  •      inspectedTitle = item.title;
    
  •    },
    
  •  },
    
  • });
  • await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
  • expect(inspectedTitle).toBe('Confirm homepage information architecture');
  • });
  • it('reports task completion changes to its owner', async () => {
  • const channel = workspace.channels.find((item) => item.type === 'tasks');
  • if (!channel) throw new Error('missing task channel');
  • const onToggleTask = vi.fn();
  • render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
  • const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
  • expect(completion).not.toBeChecked();
  • await fireEvent.click(completion);
  • expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
  • }); +}); diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte new file mode 100644 index 0000000..9232111 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.svelte @@ -0,0 +1,53 @@ +<script lang="ts">
  • import type { InspectorItem } from './types';
  • export let item: InspectorItem | null;
  • let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
  • let copyFeedback = '';
  • let currentItem: InspectorItem | null = null;
  • $: if (item !== currentItem) {
  • currentItem = item;
  • copyFeedback = '';
  • }
  • function copyLink() {
  • const link = new URL(?item=${encodeURIComponent(item?.title ?? '')}, window.location.href).toString();
  • void navigator.clipboard?.writeText(link);
  • copyFeedback = 'Link copied to clipboard.';
  • } +</script>

+

diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte new file mode 100644 index 0000000..251682b --- /dev/null +++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte @@ -0,0 +1,67 @@ +<script lang="ts">
  • import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
  • export let project: WorkbenchProject;
  • export let channels: WorkbenchChannel[];
  • export let selectedChannelID: string;
  • export let tags: string[];
  • export let recentSessions: AISessionItem[];
  • export let onSelectChannel: (channelID: string) => void;
  • const channelIcons: Record<WorkbenchChannel['icon'], string> = {
  • home: '\u2302',
  • mail: '\u2709',
  • list: '\u2630',
  • sparkles: '\u2726',
  • file: '\u25A4',
  • clock: '\u25F7',
  • link: '\u21AA',
  • };
  • $: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder); +</script>
  • +

    diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte new file mode 100644 index 0000000..495bffc --- /dev/null +++ b/apps/web/src/features/workbench/ProjectRail.svelte @@ -0,0 +1,26 @@ +<script lang="ts">

    • import type { WorkbenchProject } from './types';
    • export let projects: WorkbenchProject[];
    • export let selectedProjectID: number;
    • export let onSelectProject: (projectID: number) => void; +</script>

    +

    • SA
    • {#each projects as project}
    • <button
    •  class:active={project.id === selectedProjectID}
      
    •  aria-pressed={project.id === selectedProjectID}
      
    •  aria-label={project.name}
      
    •  title={project.name}
      
    •  on:click={() => onSelectProject(project.id)}
      
    •  <span>{project.initials}</span>
      
    •  {#if project.unreadCount}
      
    •    <small>{project.unreadCount}</small>
      
    •  {/if}
      
    • {/each}
    • + +
    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>
  • +

    • <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
    • <ProjectChannelSidebar
    •  project={workspace.project}
      
    •  channels={workspace.channels}
      
    •  {selectedChannelID}
      
    •  tags={workspace.tags}
      
    •  recentSessions={workspace.recentSessions}
      
    •  onSelectChannel={selectChannel}
      
    • />
    •  <ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
      

    +

    diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts new file mode 100644 index 0000000..0247755 --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ProjectWorkbench from './ProjectWorkbench.svelte'; + +describe('ProjectWorkbench', () => {

    • it('renders project rail, channel sidebar, topbar, and inspector', () => {
    • render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
    • expect(screen.getByLabelText('Project list')).toBeInTheDocument();
    • expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
    • expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
    • expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
    • expect(screen.getByLabelText('Global search')).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 () => {
    • 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' }));
    • expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.');
    • expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
    • }); +}); diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte new file mode 100644 index 0000000..33f7564 --- /dev/null +++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte @@ -0,0 +1,16 @@ +<script lang="ts">
    • export let account: string; +</script>

    +

    • SenlinAI
    • Search
    • <button aria-label={Account ${account}} disabled>{account.slice(0, 2).toUpperCase()}

    +

    diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte new file mode 100644 index 0000000..3b52383 --- /dev/null +++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte @@ -0,0 +1,27 @@ +<script lang="ts">

    • import type { AISessionItem, InspectorItem } from '../types';
    • export let sessions: AISessionItem[];
    • export let onInspect: (item: InspectorItem) => void;
    • let selectedSessionID = sessions[0]?.id;
    • $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
    • function inspectSession(session: AISessionItem) {
    • onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
    • } +</script>

    +

    • Project intelligence

      AI Sessions

    •  {#each sessions as session}
      
    •    <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
      
    •  {/each}
      
    • {#if selectedSession}
    •  <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
      
    • {/if}

    +

    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>

    +

    • Scheduled work

      Cron Plans

    • {#each plans as plan}
    •  <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
      
    • {/each}

    +

    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..0457feb --- /dev/null +++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte @@ -0,0 +1,25 @@ +<script lang="ts">

    • import type { WorkbenchChannel } from '../types';
    • export let channel: WorkbenchChannel;
    • let copyStatus = '';
    • async function copyUrl() {
    • try {
    •  await navigator.clipboard?.writeText(channel.url ?? '');
      
    •  copyStatus = 'URL copied.';
      
    • } catch {
    •  copyStatus = 'Copy unavailable in this browser.';
      
    • }
    • } +</script>

    +

    • External resource

      {channel.title}

    • {channel.url}

    • Open external channel
    • Copy URL
    • {#if copyStatus}
    • {copyStatus}

    • {/if} +
    diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte new file mode 100644 index 0000000..2d25d63 --- /dev/null +++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte @@ -0,0 +1,22 @@ +<script lang="ts">
  • import type { InboxMessage, InspectorItem } from '../types';
  • export let messages: InboxMessage[];
  • export let onInspect: (item: InspectorItem) => void;
  • function inspectMessage(message: InboxMessage) {
  • onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
  • } +</script>
  • +

    • Message Flow

      Inbox

    • {#each messages as message}
    •  <article class="message-row">
      
    •    <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
      
    •    <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
      
    •  </article>
      
    • {/each}

    +

    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>

    +

    • Project reference

      Notes & Sources

    • {#each items as item}
    •  <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
      
    • {/each}

    +

    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>

    +

    • {workspace.project.name}

    • Overview

    • {#each metrics(workspace) as metric}
    •  <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
      
    •    <strong>{metric.value}</strong>
      
    •    <span>{metric.label}</span>
      
    •  </button>
      
    • {/each}

    +

    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> + +

    • Work Plan

      Tasks

    • {#each tasks as task}
    •  <article class:completed={task.completed} class="task-card">
      
    •    <input
      
    •      class="task-check"
      
    •      type="checkbox"
      
    •      aria-label={`Mark ${task.title} complete`}
      
    •      checked={task.completed}
      
    •      on:change={() => onToggleTask(task.id)}
      
    •    />
      
    •    <div>
      
    •      <h2>{task.title}</h2>
      
    •      <p>{task.summary}</p>
      
    •      <small>{task.owner} / {task.due} / #{task.tag}</small>
      
    •      <span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
      
    •    </div>
      
    •    <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
      
    •  </article>
      
    • {/each}

    +

    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..cfad6a2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,165 +1,356 @@ :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; } + +.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; } }