# Final Review Package v5 ## Base 4fc9271 ## Head 1fcec3e0b5f09ef238a0596344f8d478ee1e55c4 ## Commits 1fcec3e chore: remove sdd task reports 7b1bbf1 chore: remove last sdd scratch report 884148b fix web review validation and clipboard tests ec59dee chore: remove final review scratch report 2d82705 fix: resolve final workbench re-review findings e289b1d chore: remove final sdd scratch artifact 27ff659 fix: resolve final workbench review findings 43d0b9f chore: remove sdd scratch artifact ba4ca16 fix: resolve workbench final review findings 6341a6b test: cover project channel workbench flow 7086b8b fix: style active inspector view 092930e style: apply workbench visual system 1a82a8b fix: address workbench inspector review findings 3194cbe feat: add workbench channel templates 95dce81 feat: add project channel workbench shell 6c12d4b feat: add project workspace data model 5b95668 feat: add full workbench login page ## Stat apps/web/e2e/project-workbench.spec.ts | 80 ++++- apps/web/src/app/App.svelte | 77 +--- apps/web/src/features/auth/ServerLogin.svelte | 140 +++++++- apps/web/src/features/auth/ServerLogin.test.ts | 102 ++++++ .../src/features/workbench/ChannelContent.svelte | 32 ++ .../src/features/workbench/ChannelContent.test.ts | 64 ++++ .../src/features/workbench/ObjectInspector.svelte | 67 ++++ .../src/features/workbench/ObjectInspector.test.ts | 73 ++++ .../workbench/ProjectChannelSidebar.svelte | 67 ++++ apps/web/src/features/workbench/ProjectRail.svelte | 26 ++ .../src/features/workbench/ProjectWorkbench.svelte | 61 ++++ .../features/workbench/ProjectWorkbench.test.ts | 113 ++++++ .../src/features/workbench/WorkspaceTopbar.svelte | 16 + .../workbench/channels/AISessionsChannel.svelte | 27 ++ .../features/workbench/channels/CronChannel.svelte | 19 + .../workbench/channels/CustomLinkChannel.svelte | 34 ++ .../workbench/channels/CustomLinkChannel.test.ts | 70 ++++ .../workbench/channels/InboxChannel.svelte | 22 ++ .../workbench/channels/NotesSourcesChannel.svelte | 19 + .../workbench/channels/OverviewChannel.svelte | 29 ++ .../workbench/channels/TasksChannel.svelte | 36 ++ apps/web/src/features/workbench/mockData.test.ts | 29 ++ apps/web/src/features/workbench/mockData.ts | 61 ++++ apps/web/src/features/workbench/types.ts | 93 +++++ apps/web/src/index.css | 392 +++++++++++++++------ 25 files changed, 1556 insertions(+), 193 deletions(-) ## Diff diff --git a/apps/web/e2e/project-workbench.spec.ts b/apps/web/e2e/project-workbench.spec.ts index 8a2358a..1b67f8f 100644 --- a/apps/web/e2e/project-workbench.spec.ts +++ b/apps/web/e2e/project-workbench.spec.ts @@ -1,17 +1,79 @@ import { expect, test } from '@playwright/test'; -test('project workbench shell renders', async ({ page }) => { +test('login and project channel workbench flow', async ({ page }) => { await page.goto('/'); - await expect(page.getByText('项目工作台')).toBeVisible(); - await expect(page.getByLabel('服务器登录')).toBeVisible(); - await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible(); - await expect(page.getByLabel('项目总览')).toBeVisible(); + + await expect(page.getByLabel('Server IP or domain')).toBeVisible(); + await page.getByLabel('Server IP or domain').fill('localhost:8080'); + await page.getByLabel('Email or username').fill('david@example.com'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByLabel('Project list')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible(); + + await page.getByRole('button', { name: 'Work Plan 8' }).click(); + await expect(page.getByText('Tasks')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible(); + + await page.getByRole('button', { name: 'Project A2' }).click(); + await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByText('Ops Dashboard')).toBeVisible(); + + await page.getByRole('button', { name: 'Ops Dashboard' }).click(); + await expect(page.getByText('Open external channel')).toBeVisible(); }); -test('inbox suggestions require explicit confirmation', async ({ page }) => { +for (const server of [ + 'https://user:secret@example.com', + 'http:////example.com', + 'http//example.com', + 'https//example.com', + 'http:/example.com', + 'https:/example.com', + 'http://example.com\\path', + 'http://example..com', + 'http://-bad.com', +]) { + test(`rejects malformed server address: ${server}`, async ({ page }) => { + await page.goto('/'); + + await page.getByLabel('Server IP or domain').fill(server); + await page.getByLabel('Email or username').fill('david@example.com'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible(); + await expect(page.getByLabel('Server IP or domain')).toBeVisible(); + }); +} + +test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); - await page.getByLabel('选择 跟进报价').check(); - await page.getByRole('button', { name: '创建选中项' }).click(); - await expect(page.getByText('已选择 1 项')).toBeVisible(); + await page.getByLabel('Server IP or domain').fill('localhost:8080'); + await page.getByLabel('Email or username').fill('david@example.com'); + await page.getByLabel('Password').fill('secret'); + await page.getByRole('button', { name: 'Log in' }).click(); + + const rail = page.getByLabel('Project list'); + const sidebar = page.getByLabel('Project channels'); + const stage = page.getByLabel('Channel content'); + const inspector = page.getByLabel('Object inspector'); + + await expect(rail).toHaveCSS('grid-auto-flow', 'column'); + await expect(rail).toHaveCSS('max-height', '76px'); + await expect(inspector).toHaveCSS('max-height', '288px'); + + const workPlan = page.getByRole('button', { name: 'Work Plan 8' }); + await expect(workPlan).toBeVisible(); + await expect(workPlan).toBeInViewport(); + await workPlan.click(); + await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible(); + + const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]); + expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0); + expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248); }); diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte index a4c6389..e7170ef 100644 --- a/apps/web/src/app/App.svelte +++ b/apps/web/src/app/App.svelte @@ -1,76 +1,19 @@ -
- - -
- -
-

AI 整理建议

- -

已选择 {selectedSuggestionCount} 项

-
-
-
+{#if currentUser} + +{:else} + +{/if} diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte index 8e80261..147724d 100644 --- a/apps/web/src/features/auth/ServerLogin.svelte +++ b/apps/web/src/features/auth/ServerLogin.svelte @@ -1,28 +1,134 @@ - +
+ +
diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts new file mode 100644 index 0000000..e8a617c --- /dev/null +++ b/apps/web/src/features/auth/ServerLogin.test.ts @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ServerLogin from './ServerLogin.svelte'; + +describe('ServerLogin', () => { + it('renders server, account, and password fields', () => { + render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } }); + + expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument(); + expect(screen.getByLabelText('Email or username')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.'); + }); + + it('normalizes server address and emits login details', async () => { + const onLogin = vi.fn(); + render(ServerLogin, { + props: { + apiBase: 'localhost:8080', + onLogin, + }, + }); + + await fireEvent.input(screen.getByLabelText('Server IP or domain'), { + target: { value: '10.0.0.12:8080' }, + }); + await fireEvent.input(screen.getByLabelText('Email or username'), { + target: { value: 'david@example.com' }, + }); + await fireEvent.input(screen.getByLabelText('Password'), { + target: { value: 'secret' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(onLogin).toHaveBeenCalledWith({ + apiBase: 'http://10.0.0.12:8080', + account: 'david@example.com', + }); + }); + + it.each([ + ['localhost', 'http://localhost'], + ['localhost:8080', 'http://localhost:8080'], + ['192.168.1.20:3000', 'http://192.168.1.20:3000'], + ['example.com:8443', 'http://example.com:8443'], + ['https://example.com', 'https://example.com'], + ])('accepts and normalizes a valid server value: %s', async (server, apiBase) => { + const onLogin = vi.fn(); + render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } }); + + await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } }); + await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } }); + await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' }); + }); + + it('shows an error when account or password is missing', async () => { + render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(screen.getByText('Enter an account and password.')).toBeInTheDocument(); + }); + + it.each([ + ['', 'Enter a server address.'], + ['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'], + [' https://example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'], + ['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://', 'Enter a valid HTTP or HTTPS server address.'], + ['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http//example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https//example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'], + ['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'], + ['http://example..com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'], + ['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'], + ])('rejects an invalid server value: %s', async (server, error) => { + const onLogin = vi.fn(); + render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } }); + + await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } }); + await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } }); + await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(screen.getByText(error)).toBeInTheDocument(); + expect(onLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte new file mode 100644 index 0000000..6d18e6a --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.svelte @@ -0,0 +1,32 @@ + + +{#if channel.type === 'overview'} + +{:else if channel.type === 'inbox'} + +{:else if channel.type === 'tasks'} + +{:else if channel.type === 'ai_sessions'} + +{:else if channel.type === 'notes_sources'} + +{:else if channel.type === 'cron'} + +{:else} + +{/if} diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts new file mode 100644 index 0000000..e5887db --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.test.ts @@ -0,0 +1,64 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData'; + +describe('ChannelContent', () => { + const workspace = getProjectWorkspace(1); + + it('renders a distinctive heading or control for every channel type', () => { + const expectedContent = { + overview: { role: 'heading', name: 'Overview' }, + inbox: { role: 'heading', name: 'Inbox' }, + tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' }, + ai_sessions: { role: 'heading', name: 'AI Sessions' }, + notes_sources: { role: 'heading', name: 'Notes & Sources' }, + cron: { role: 'heading', name: 'Cron Plans' }, + custom_link: { role: 'link', name: 'Open external channel' }, + } as const; + + for (const type of Object.keys(expectedContent) as Array) { + const channel = workspace.channels.find((item) => item.type === type); + if (!channel) throw new Error(`missing ${type}`); + + const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); + expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument(); + unmount(); + } + }); + + it('sends selected task details to inspector', async () => { + let inspectedTitle = ''; + const channel = workspace.channels.find((item) => item.type === 'tasks'); + if (!channel) throw new Error('missing task channel'); + + render(ChannelContent, { + props: { + workspace, + channel, + onInspect: (item) => { + inspectedTitle = item.title; + }, + }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + expect(inspectedTitle).toBe('Confirm homepage information architecture'); + }); + + it('reports task completion changes to its owner', async () => { + const channel = workspace.channels.find((item) => item.type === 'tasks'); + if (!channel) throw new Error('missing task channel'); + const onToggleTask = vi.fn(); + + render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } }); + + const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' }); + expect(completion).not.toBeChecked(); + + await fireEvent.click(completion); + + expect(onToggleTask).toHaveBeenCalledWith('1-task-1'); + }); +}); diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte new file mode 100644 index 0000000..6fbe503 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.svelte @@ -0,0 +1,67 @@ + + + diff --git a/apps/web/src/features/workbench/ObjectInspector.test.ts b/apps/web/src/features/workbench/ObjectInspector.test.ts new file mode 100644 index 0000000..b586131 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.test.ts @@ -0,0 +1,73 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ObjectInspector from './ObjectInspector.svelte'; + +const item = { + title: 'Review navigation', + type: 'Task', + description: 'Check the project navigation flow.', + properties: [], +}; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + +function setClipboard(writeText: Clipboard['writeText']) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } as Clipboard, + }); +} + +afterEach(() => { + cleanup(); + if (originalClipboard) { + Object.defineProperty(navigator, 'clipboard', originalClipboard); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } +}); + +describe('ObjectInspector', () => { + async function openMoreActions() { + await fireEvent.click(screen.getByRole('button', { name: 'More' })); + } + + it('shows copy success only after clipboard writing resolves', async () => { + let resolveWrite: (() => void) | undefined; + const writeText = vi.fn( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + setClipboard(writeText); + render(ObjectInspector, { props: { item } }); + + await openMoreActions(); + await fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + resolveWrite?.(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.')); + }); + + it('reports a copy failure when clipboard writing rejects', async () => { + setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied'))); + render(ObjectInspector, { props: { item } }); + + await openMoreActions(); + await fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.')); + }); + + it('reports unavailable clipboard access without showing success', async () => { + Reflect.deleteProperty(navigator, 'clipboard'); + render(ObjectInspector, { props: { item } }); + + await openMoreActions(); + await fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.'); + }); +}); diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte new file mode 100644 index 0000000..251682b --- /dev/null +++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte @@ -0,0 +1,67 @@ + + + diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte new file mode 100644 index 0000000..495bffc --- /dev/null +++ b/apps/web/src/features/workbench/ProjectRail.svelte @@ -0,0 +1,26 @@ + + + diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte new file mode 100644 index 0000000..f95fa73 --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte @@ -0,0 +1,61 @@ + + +
+ +
+ + +
+ +
+ +
+
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts new file mode 100644 index 0000000..1fda28b --- /dev/null +++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts @@ -0,0 +1,113 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; +import ProjectWorkbench from './ProjectWorkbench.svelte'; + +describe('ProjectWorkbench', () => { + it('renders project rail, channel sidebar, topbar, and inspector', () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + expect(screen.getByLabelText('Project list')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Project channels')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument(); + expect(screen.getByLabelText('Global search')).toBeDisabled(); + expect(screen.getByLabelText('Object inspector')).toBeInTheDocument(); + }); + + it('refreshes channels when switching projects', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + + expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Ops Dashboard')).toBeInTheDocument(); + }); + + it('marks active channel with aria-pressed', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + + expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('shows inspected task details in the inspector', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + + expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument(); + + await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' })); + + expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(inspector).getByText('Status')).toBeInTheDocument(); + expect(within(inspector).getByText('Open')).toBeInTheDocument(); + expect(within(inspector).getByText('Owner')).toBeInTheDocument(); + expect(within(inspector).getByText('David')).toBeInTheDocument(); + }); + + it('clears the inspector when the project or channel changes', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument(); + expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' })); + expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument(); + }); + + it('preserves task completion when switching channels within a project', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' }); + await fireEvent.click(task); + expect(task).toBeChecked(); + + await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' })); + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked(); + + await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked(); + }); + + it('provides Copy link feedback and disables unavailable archive actions', async () => { + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard, + }); + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + await fireEvent.click(within(inspector).getByRole('button', { name: 'More' })); + await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' })); + + await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.')); + expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled(); + + if (originalClipboard) { + Object.defineProperty(navigator, 'clipboard', originalClipboard); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } + }); +}); diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte new file mode 100644 index 0000000..33f7564 --- /dev/null +++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte @@ -0,0 +1,16 @@ + + +
+
SenlinAI
+ +
+ + + +
+
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 @@ + + +
+

Project intelligence

AI Sessions

+
+
+ {#each sessions as session} + + {/each} +
+ {#if selectedSession} +

{selectedSession.title}

{selectedSession.summary}

References: {selectedSession.references.join(', ')}

+ {/if} +
+
diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte new file mode 100644 index 0000000..7a6f2c3 --- /dev/null +++ b/apps/web/src/features/workbench/channels/CronChannel.svelte @@ -0,0 +1,19 @@ + + +
+

Scheduled work

Cron Plans

+
+ {#each plans as plan} +

{plan.title}

{plan.schedule} · {plan.nextRun}

{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}
+ {/each} +
+
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte new file mode 100644 index 0000000..57a3ff2 --- /dev/null +++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte @@ -0,0 +1,34 @@ + + + diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts new file mode 100644 index 0000000..e968295 --- /dev/null +++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.test.ts @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import CustomLinkChannel from './CustomLinkChannel.svelte'; + +const channel = { + id: 'project-custom-link', + projectID: 1, + type: 'custom_link' as const, + title: 'Roadmap Board', + icon: 'link', + url: 'https://example.com/roadmap', + sortOrder: 1, +}; +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + +function setClipboard(writeText: Clipboard['writeText']) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } as Clipboard, + }); +} + +afterEach(() => { + cleanup(); + if (originalClipboard) { + Object.defineProperty(navigator, 'clipboard', originalClipboard); + } else { + Reflect.deleteProperty(navigator, 'clipboard'); + } +}); + +describe('CustomLinkChannel', () => { + it('shows copy success only after clipboard writing resolves', async () => { + let resolveWrite: (() => void) | undefined; + const writeText = vi.fn( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + setClipboard(writeText); + render(CustomLinkChannel, { props: { channel } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + + expect(screen.queryByText('URL copied.')).not.toBeInTheDocument(); + resolveWrite?.(); + await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument()); + }); + + it('reports a copy failure when clipboard writing rejects', async () => { + setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied'))); + render(CustomLinkChannel, { props: { channel } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + + await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument()); + }); + + it('reports unavailable clipboard access without showing URL copied', async () => { + Reflect.deleteProperty(navigator, 'clipboard'); + render(CustomLinkChannel, { props: { channel } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' })); + + await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument()); + expect(screen.queryByText('URL copied.')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte new file mode 100644 index 0000000..2d25d63 --- /dev/null +++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte @@ -0,0 +1,22 @@ + + +
+

Message Flow

Inbox

+
+ {#each messages as message} +
+
{message.source} · {message.time}

{message.title}

{message.summary}

{message.status} · #{message.tag}
+ +
+ {/each} +
+
diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte new file mode 100644 index 0000000..256ef8b --- /dev/null +++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte @@ -0,0 +1,19 @@ + + +
+

Project reference

Notes & Sources

+
+ {#each items as item} +
{item.kind}

{item.title}

{item.source} · {item.updatedAt} · #{item.tag}
+ {/each} +
+
diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte new file mode 100644 index 0000000..6d478cf --- /dev/null +++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte @@ -0,0 +1,29 @@ + + +
+
+

{workspace.project.name}

+

Overview

+
+
+ {#each metrics(workspace) as metric} + + {/each} +
+
diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte new file mode 100644 index 0000000..8639b22 --- /dev/null +++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte @@ -0,0 +1,36 @@ + + +
+

Work Plan

Tasks

+
+ {#each tasks as task} +
+ onToggleTask(task.id)} + /> +
+

{task.title}

+

{task.summary}

+ {task.owner} / {task.due} / #{task.tag} + {task.completed ? 'Completed' : 'Open'} +
+ +
+ {/each} +
+
diff --git a/apps/web/src/features/workbench/mockData.test.ts b/apps/web/src/features/workbench/mockData.test.ts new file mode 100644 index 0000000..fdbf451 --- /dev/null +++ b/apps/web/src/features/workbench/mockData.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { getProjectWorkspace, workbenchProjects } from './mockData'; + +describe('workbench mock data', () => { + it('provides dynamic projects', () => { + expect(workbenchProjects.length).toBeGreaterThanOrEqual(2); + expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) }); + }); + + it('loads project-specific channels and counts', () => { + const first = getProjectWorkspace(workbenchProjects[0].id); + const second = getProjectWorkspace(workbenchProjects[1].id); + + expect(first.project.id).not.toBe(second.project.id); + expect(first.channels.map((channel) => channel.type)).toEqual([ + 'overview', + 'inbox', + 'tasks', + 'ai_sessions', + 'notes_sources', + 'cron', + 'custom_link', + ]); + expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({ + title: expect.any(String), + url: expect.stringMatching(/^https?:\/\//), + }); + }); +}); diff --git a/apps/web/src/features/workbench/mockData.ts b/apps/web/src/features/workbench/mockData.ts new file mode 100644 index 0000000..90a25c2 --- /dev/null +++ b/apps/web/src/features/workbench/mockData.ts @@ -0,0 +1,61 @@ +import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types'; + +export const workbenchProjects: WorkbenchProject[] = [ + { id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 }, + { id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 }, +]; + +function systemChannels(projectID: number): WorkbenchChannel[] { + return [ + { id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 }, + { id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 }, + { id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 }, + { id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 }, + { id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 }, + { id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 }, + { + id: `${projectID}-custom-roadmap`, + projectID, + type: 'custom_link', + title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard', + icon: 'link', + url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2', + sortOrder: 7, + }, + ]; +} + +const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({ + project, + channels: systemChannels(project.id), + tags: ['all', 'tag1', 'UI', 'knowledge'], + recentSessions: [ + { id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] }, + { id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] }, + ], + inbox: [ + { id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' }, + { id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' }, + ], + tasks: [ + { id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' }, + { id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' }, + ], + aiSessions: [ + { id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] }, + { id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] }, + ], + notesSources: [ + { id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' }, + { id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' }, + { id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' }, + ], + cronPlans: [ + { id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' }, + { id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' }, + ], +})); + +export function getProjectWorkspace(projectID: number): ProjectWorkspace { + return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0]; +} diff --git a/apps/web/src/features/workbench/types.ts b/apps/web/src/features/workbench/types.ts new file mode 100644 index 0000000..98ae0f9 --- /dev/null +++ b/apps/web/src/features/workbench/types.ts @@ -0,0 +1,93 @@ +export type ChannelType = + | 'overview' + | 'inbox' + | 'tasks' + | 'ai_sessions' + | 'notes_sources' + | 'cron' + | 'custom_link'; + +export type WorkbenchProject = { + id: number; + name: string; + description: string; + initials: string; + unreadCount: number; +}; + +export type WorkbenchChannel = { + id: string; + projectID: number; + type: ChannelType; + title: string; + icon: string; + count?: number; + url?: string; + sortOrder: number; +}; + +export type InboxMessage = { + id: string; + source: string; + title: string; + summary: string; + status: 'open' | 'processed' | 'archived'; + tag: string; + time: string; +}; + +export type WorkTask = { + id: string; + title: string; + summary: string; + completed: boolean; + owner: string; + due: string; + tag: string; +}; + +export type AISessionItem = { + id: string; + title: string; + summary: string; + updatedAt: string; + references: string[]; +}; + +export type NoteSourceItem = { + id: string; + kind: 'note' | 'file' | 'link'; + title: string; + updatedAt: string; + tag: string; + source: string; +}; + +export type CronPlan = { + id: string; + title: string; + schedule: string; + nextRun: string; + enabled: boolean; + lastResult: string; + owner: string; +}; + +export type InspectorItem = { + title: string; + type: string; + description: string; + properties: Array<{ label: string; value: string }>; +}; + +export type ProjectWorkspace = { + project: WorkbenchProject; + channels: WorkbenchChannel[]; + tags: string[]; + recentSessions: AISessionItem[]; + inbox: InboxMessage[]; + tasks: WorkTask[]; + aiSessions: AISessionItem[]; + notesSources: NoteSourceItem[]; + cronPlans: CronPlan[]; +}; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7a1e945..ea3f080 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,165 +1,357 @@ :root { - color: #202124; - background: #f6f7f9; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #18181b; + background: #f4f4f5; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; + --background: #f4f4f5; + --panel: #ffffff; + --panel-muted: #fafafa; + --border: #d4d4d8; + --text: #18181b; + --muted: #71717a; + --primary: #0f7ae5; + --primary-strong: #0969c8; + --success: #15803d; + --danger: #b91c1c; + --radius: 8px; } -* { - box-sizing: border-box; -} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +button, input, textarea, select { font: inherit; } +button { cursor: pointer; } +button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: 0.55; } -body { - margin: 0; - min-width: 320px; +.login-page { min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: var(--background); } -button, -input, -textarea, -select { - font: inherit; +.login-panel { + width: min(420px, 100%); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + padding: 24px; } -.shell { +.server-login, +.login-heading, +.channel-page, +.object-inspector, +.channel-sidebar { display: grid; - grid-template-columns: 280px 1fr; - min-height: 100vh; + gap: 12px; } -.sidebar { - border-right: 1px solid #d9dde3; - background: #ffffff; - padding: 24px; +.server-login input, +.search-box input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 10px; + background: white; +} + +.server-login button, +.primary-action { + border: 1px solid var(--primary); + border-radius: 6px; + background: var(--primary); + color: white; + padding: 9px 12px; } -.sidebar h1 { - margin: 0 0 24px; - font-size: 24px; +.connection-status, +.login-note, +.form-error, +.channel-header p, +.channel-page small, +.channel-page > p, +.object-inspector > p { + margin: 0; + color: var(--muted); + font-size: 0.875rem; } -.workspace { +.form-error { color: var(--danger); } + +.workbench-shell { + min-height: 100vh; display: grid; - gap: 24px; - padding: 24px; + grid-template-rows: 64px 1fr; } -.connection-status, -.selection-status { - margin: 12px 0 0; - color: #5f6673; - font-size: 13px; +.workspace-topbar { + display: grid; + grid-template-columns: 160px minmax(240px, 1fr) auto; + align-items: center; + gap: 16px; + border-bottom: 1px solid var(--border); + background: var(--panel); + padding: 0 16px; } -.server-login { +.brand-mark { font-weight: 700; } +.search-box { display: grid; gap: 4px; min-width: 0; } +.search-box span { color: var(--muted); font-size: 0.75rem; } +.topbar-actions { display: flex; gap: 8px; } +.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); } + +.workbench-body { + min-height: 0; display: grid; - gap: 8px; + grid-template-columns: 76px 280px minmax(0, 1fr) 320px; } -.server-login label { - font-size: 13px; - color: #4b5563; +.project-rail, +.channel-sidebar, +.object-inspector { + border-right: 1px solid var(--border); + background: var(--panel); } -.server-login input { - width: 100%; - border: 1px solid #c7ccd4; - border-radius: 6px; - padding: 9px 10px; +.project-rail { + display: grid; + align-content: start; + gap: 10px; + padding: 12px; } -.server-login button { - border: 1px solid #1f2937; - border-radius: 6px; - background: #1f2937; - color: white; - padding: 9px 10px; - cursor: pointer; +.project-rail button { + min-width: 48px; + min-height: 48px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-muted); } -.dashboard-grid { - display: grid; - grid-template-columns: repeat(4, minmax(160px, 1fr)); - gap: 12px; +.project-rail button.active, +.channel-group button.active { + border-color: var(--primary); + color: var(--primary); + background: #eff6ff; } -.dashboard-metric { - border: 1px solid #d9dde3; - border-radius: 8px; - background: white; +.project-rail small { color: var(--muted); font-size: 0.6875rem; } + +.channel-sidebar, +.object-inspector, +.channel-stage { padding: 16px; + overflow: auto; } -.dashboard-metric span { - display: block; - color: #5f6673; - font-size: 14px; +.channel-sidebar { + align-content: start; + justify-items: start; } -.dashboard-metric strong { - display: block; - margin-top: 8px; - font-size: 28px; +.channel-sidebar > header, +.channel-sidebar > .channel-group, +.channel-sidebar > .recent-sessions { + width: 100%; } -.inbox-review { +.channel-group, +.task-list, +.record-list, +.message-list, +.file-list, +.cron-list { display: grid; - gap: 12px; + gap: 8px; } -.inbox-review h2 { - margin: 0; - font-size: 18px; +.channel-group button, +.task-card, +.record-row, +.cron-row, +.message-row, +.file-row, +.conversation-summary, +.metric-card { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + padding: 10px 12px; } -.suggestions { +.task-card, +.record-row, +.cron-row, +.message-row, +.file-row { display: grid; - gap: 10px; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: start; } -.suggestion { - display: grid; - grid-template-columns: 20px 1fr; - gap: 10px; - align-items: start; - border: 1px solid #d9dde3; - border-radius: 8px; - background: white; - padding: 12px; +.task-card.completed { + color: var(--muted); + background: var(--panel-muted); } -.suggestion-body { +.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); } +.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; } + +.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.channel-sidebar header p, +.channel-sidebar h2, +.channel-sidebar h3 { margin: 0; } +.channel-sidebar h2 { font-size: 1rem; } +.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; } +.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); } +.tag-row, +.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; } +.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; } +.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; } +.channel-group small { color: var(--muted); } +.channel-icon { color: var(--muted); text-align: center; } +.recent-sessions { display: grid; gap: 8px; } +.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); } +.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; } +.recent-sessions small { color: var(--muted); font-size: 0.6875rem; } + +.channel-stage { background: var(--background); } +.channel-page { align-content: start; } +.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; } +.channel-header h1, +.channel-page h2 { margin: 0; } +.channel-header h1 { font-size: 1.25rem; } +.channel-page h2 { font-size: 0.9375rem; } +.channel-page h2 + p { margin: 4px 0; } +.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; } +.metric-card { display: grid; gap: 4px; text-align: left; } +.metric-card strong { font-size: 1.5rem; } +.metric-card span { color: var(--muted); font-size: 0.8125rem; } +.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } +.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; } +.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; } +.message-row p, +.cron-row p, +.conversation-summary p { margin: 4px 0; } +.message-row button, +.file-row button, +.task-card > button, +.cron-row button, +.conversation-summary button, +.copy-url-button, +.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; } +.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; } +.session-list { display: grid; align-content: start; gap: 6px; } +.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; } +.session-list button.active { border-color: var(--primary); background: #eff6ff; } +.session-list small { color: var(--muted); } +.conversation-summary { align-content: start; } +.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; } +.copy-url-button { width: max-content; } +.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; } +.copy-feedback.copy-error { color: var(--danger); } + +.object-inspector { + align-content: start; + grid-auto-rows: min-content; +} + +.inspector-tabs { display: grid; + grid-template-columns: repeat(3, 1fr); + align-content: start; + grid-auto-rows: min-content; gap: 4px; } -.suggestion-body small { - color: #5f6673; +.inspector-tabs button[aria-pressed='true'] { + border-color: var(--primary); + color: var(--primary); } -.suggestions button { - justify-self: start; - border: 1px solid #1f2937; - border-radius: 6px; - background: #1f2937; - color: white; - padding: 9px 12px; - cursor: pointer; -} +@media (max-width: 920px) { + .workspace-topbar { + grid-template-columns: minmax(0, 1fr) auto; + height: auto; + padding: 12px; + } + + .search-box { grid-column: 1 / -1; grid-row: 2; } + .topbar-actions { grid-column: 2; grid-row: 1; } -@media (max-width: 760px) { - .shell { + .workbench-shell { + grid-template-rows: auto 1fr; + } + + .workbench-body { grid-template-columns: 1fr; + align-content: start; } - .sidebar { + .project-rail, + .channel-sidebar, + .object-inspector { border-right: 0; - border-bottom: 1px solid #d9dde3; + border-bottom: 1px solid var(--border); } - .dashboard-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .project-rail { + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: 48px; + align-items: center; + max-height: 76px; + overflow-x: auto; + overflow-y: hidden; + padding: 10px 12px; + } + + .project-rail button { min-width: 48px; min-height: 48px; } + + .channel-sidebar { + align-content: start; + max-height: 220px; + padding: 12px; } + + .channel-group, + .tag-row, + .recent-sessions ul { + grid-auto-flow: column; + grid-auto-columns: minmax(132px, max-content); + grid-template-columns: none; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 2px; + } + + .channel-group button { min-width: 160px; } + .channel-group { + grid-auto-flow: row; + grid-auto-columns: auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); + overflow: visible; + } + + .channel-group button { + min-width: 0; + min-height: 48px; + grid-template-columns: 18px minmax(0, 1fr); + padding: 8px; + } + + .channel-group button > span:last-of-type { + overflow: hidden; + text-overflow: ellipsis; + } + + .channel-group small { display: none; } + .recent-sessions li { min-width: 160px; } + .channel-stage { max-height: 36vh; overflow: auto; } + .object-inspector { max-height: 288px; overflow: auto; } + .session-layout { grid-template-columns: 1fr; } }