diff --git a/README.md b/README.md index 8af2b8ce..b9b42aea 100644 --- a/README.md +++ b/README.md @@ -171,9 +171,9 @@ Spawn autonomous coding agents on tasks when you choose to connect an agent runn ![Resizable Workbench panel](docs/assets/v6.1.6/workbench-panel.png) -Desktop Board Chat and Squad Chat open in a bounded right-side Workbench dock by -default. Switch to Bottom when vertical space is preferable; both orientations -keep the board, header, close control, and keyboard recovery paths reachable. +Desktop Board Chat and Squad Chat open in one bounded, resizable right-side +Workbench dock. The single orientation keeps the board, header, close control, +and keyboard recovery paths reachable at every supported window size. ![Squad Chat coordination](docs/assets/v6.1.6/squad-chat.png) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 40b22dfe..86a577ee 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -119,7 +119,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref - **Desktop shell controls** — Native-app-style toolbar with workspace selection, health state, view toggles, and bounded left/right/chat dock controls shared by the web and macOS app shells - **Native version identity** — The macOS application menu opens an offline About panel and copies a redacted support string from the same authoritative Electron version, embedded release commit, release channel, OS, and architecture record exposed by the desktop bridge - **Mobile shell controls** — Compact navigation uses bounded labels and full accessible names; Board Chat stays fixed above the bottom navigation and device safe area -- **Resizable Workbench** — Board Chat and Squad Chat open in a right dock by default, can switch to Bottom without losing the active conversation, and clamp their width or height to keep the application shell recoverable +- **Resizable Workbench** — Board Chat and Squad Chat open in one bounded right-side dock, preserve the active conversation when switching channels, and clamp their width to keep the application shell recoverable - **Bulk operations** — Select multiple tasks to move, archive, or delete in batch; select-all toggle - **Keyboard shortcuts** — Navigate tasks (j/k, arrows), open (Enter), close (Esc), create (c), move to column (1-4), help (?) - **Loading skeleton** — Shimmer placeholders while the board loads @@ -636,7 +636,7 @@ Real-time agent-to-agent communication channel for multi-agent collaboration. Sh | ![6.1.6 Squad Chat coordination](assets/v6.1.6/squad-chat.png) | ![6.1.6 notification and reply adapters](assets/v6.1.6/notification-adapters.png) | - **WebSocket-powered chat** — Messages broadcast in real time to all connected clients -- **Resizable Workbench dock** — Board Chat and Squad Chat share one dock that defaults Right, optionally moves to Bottom, isolates chat scrolling, and keeps Close, Escape, Back, and Reset Layout recovery available +- **Resizable Workbench dock** — Board Chat and Squad Chat share one bounded right-side dock that isolates chat scrolling and keeps Close, Escape, Back, and Reset Layout recovery available - **Local shared log** — Squad Chat stores and streams messages; it does not wake or reply through an external agent unless a webhook, OpenClaw Direct path, or orchestrator is configured - **Threaded coordination** — Reply-to links render compact threads for long multi-agent runs - **Unread and mentions** — Per-actor unread state persists across refreshes, and mentions create local notifications linked back to messages diff --git a/docs/index.html b/docs/index.html index 80eb83a1..44290fe8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1755,8 +1755,8 @@ curl -X POST .../tasks/<id>/comments \

Bounded Chat Workbench

- Board Chat and Squad Chat default to a right dock, switch to Bottom without - remounting, clamp to the viewport, and always preserve visible recovery controls. + Board Chat and Squad Chat share one bounded right-side dock, switch channels without + remounting, clamp to the viewport, and preserve visible recovery controls.

diff --git a/e2e/desktop-board-shell.spec.ts b/e2e/desktop-board-shell.spec.ts index 130b7b7f..ba6fdcf0 100644 --- a/e2e/desktop-board-shell.spec.ts +++ b/e2e/desktop-board-shell.spec.ts @@ -9,7 +9,8 @@ test.describe('Desktop board shell containment', () => { await page.addInitScript(() => { window.localStorage.setItem('veritas-kanban-theme', 'dark'); window.localStorage.setItem('veritas.desktop.rightRailOpen', 'true'); - window.localStorage.setItem('veritas.workbench.dockPosition', 'right'); + window.localStorage.setItem('veritas.workbench.dockPosition', 'bottom'); + window.localStorage.setItem('veritas.workbench.bottomPanelHeight', '640'); Object.defineProperty(window, 'veritasDesktop', { configurable: true, value: { @@ -46,12 +47,24 @@ test.describe('Desktop board shell containment', () => { await page.goto('/'); await expect(page.locator('html')).toHaveAttribute('data-client', 'desktop'); await expect(page.getByLabel('Board right sidebar')).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem('veritas.workbench.dockPosition')) + ) + .toBeNull(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem('veritas.workbench.bottomPanelHeight')) + ) + .toBeNull(); await Promise.all( taskIds.map((taskId) => expect(page.locator(`[data-task-id="${taskId}"]`)).toBeAttached()) ); await page.getByRole('button', { name: 'Open chat dock' }).click(); - await expect(page.getByRole('region', { name: 'Workbench right dock' })).toBeVisible(); + const workbench = page.getByRole('region', { name: 'Workbench right dock' }); + await expect(workbench).toBeVisible(); + await expect(workbench.getByRole('radiogroup', { name: 'Dock position' })).toHaveCount(0); const readViewport = () => page.evaluate(() => ({ @@ -68,6 +81,21 @@ test.describe('Desktop board shell containment', () => { }; expect(await readViewport()).toEqual(expectedViewport); + const resizer = page.getByRole('button', { name: 'Resize right dock' }); + const widthBeforeResize = await workbench.evaluate( + (element) => element.getBoundingClientRect().width + ); + await resizer.focus(); + await page.keyboard.press('ArrowLeft'); + await expect + .poll(() => workbench.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(widthBeforeResize); + expect(await readViewport()).toEqual(expectedViewport); + + await page.getByRole('button', { name: 'Switch to Squad Chat' }).click(); + await expect(page.getByRole('region', { name: 'Squad Chat' })).toBeVisible(); + expect(await readViewport()).toEqual(expectedViewport); + await page.getByRole('button', { name: 'Close right dock' }).click(); await expect(page.getByRole('region', { name: 'Workbench right dock' })).toHaveCount(0); diff --git a/e2e/docs-media-capture.spec.ts b/e2e/docs-media-capture.spec.ts index 212b5484..bcc8ad4a 100644 --- a/e2e/docs-media-capture.spec.ts +++ b/e2e/docs-media-capture.spec.ts @@ -21,6 +21,19 @@ async function useDarkTheme(page: Page) { }); } +async function useDesktopBridge(page: Page) { + await page.evaluate(() => { + Object.defineProperty(window, 'veritasDesktop', { + configurable: true, + value: { + onMenuCommand: () => () => undefined, + toggleWindowMaximize: async () => ({ maximized: false }), + }, + }); + }); + await page.reload(); +} + test('captures public-safe 6.1.6 desktop and mobile documentation media', async ({ page }) => { test.setTimeout(180_000); await mkdir(assetsDir, { recursive: true }); @@ -63,6 +76,7 @@ test('captures public-safe 6.1.6 desktop and mobile documentation media', async try { await page.setViewportSize(desktopViewport); await page.goto('/'); + await useDesktopBridge(page); await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({ timeout: 15_000 }); await expect( page.getByRole('heading', { name: 'Prepare the release candidate' }) @@ -102,15 +116,19 @@ test('captures public-safe 6.1.6 desktop and mobile documentation media', async await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Open Board Chat' }).click(); - await expect(page.getByRole('region', { name: 'Workbench bottom dock' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Workbench right dock' })).toBeVisible(); await expect(page.locator('section[aria-label="Board Chat"]')).toBeVisible(); await capture(page, 'workbench-panel.png'); await page.getByText('Squad Chat', { exact: true }).last().click(); await expect(page.getByRole('region', { name: 'Squad Chat' })).toBeVisible(); await capture(page, 'squad-chat.png'); - await page.getByRole('button', { name: 'Close bottom dock' }).click(); + await page.getByRole('button', { name: 'Close right dock' }).click(); await page.setViewportSize(mobileViewport); + await page.evaluate(() => { + delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; + delete document.documentElement.dataset.client; + }); await page.goto('/'); await page.getByRole('button', { name: 'Mobile board' }).click(); await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({ timeout: 15_000 }); diff --git a/web/src/App.tsx b/web/src/App.tsx index a363b782..f126759f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -120,7 +120,7 @@ function DesktopAwareAppShell({ showDesktopOnboarding: boolean; setShowDesktopOnboarding: (open: boolean) => void; }) { - const { isDesktopClient, bottomPanel, dockPosition } = useDesktopShell(); + const { isDesktopClient, bottomPanel } = useDesktopShell(); return ( @@ -132,10 +132,7 @@ function DesktopAwareAppShell({ -
+
{String(shell.leftRailOpen)} {String(shell.rightRailOpen)} {shell.bottomPanel ?? 'closed'} - {shell.dockPosition} - {shell.bottomPanelHeight} {shell.rightPanelWidth} - - @@ -78,11 +70,11 @@ describe('desktop shell recovery', () => { menuListener = undefined; }); - it('defaults invalid and legacy layout state to a bounded right dock', () => { + it('retires legacy bottom-dock state and defaults to a bounded right dock', () => { window.localStorage.setItem('veritas.desktop.bottomPanel', 'board-chat'); window.localStorage.setItem('veritas.workbench.bottomPanelHeight', '9999'); window.localStorage.setItem('veritas.workbench.rightPanelWidth', '-20'); - window.localStorage.setItem('veritas.workbench.dockPosition', 'full-window'); + window.localStorage.setItem('veritas.workbench.dockPosition', 'bottom'); render( @@ -91,37 +83,27 @@ describe('desktop shell recovery', () => { ); expect(screen.getByLabelText('bottom panel').textContent).toBe('closed'); - expect(screen.getByLabelText('dock position').textContent).toBe('right'); - expect(screen.getByLabelText('bottom panel height').textContent).toBe('280'); expect(screen.getByLabelText('right panel width').textContent).toBe( String(DEFAULT_RIGHT_PANEL_WIDTH) ); expect(window.localStorage.getItem('veritas.desktop.bottomPanel')).toBeNull(); + expect(window.localStorage.getItem('veritas.workbench.bottomPanelHeight')).toBeNull(); expect(window.localStorage.getItem('veritas.workbench.dockPosition')).toBeNull(); }); - it('persists a valid dock position without closing the conversation', async () => { + it('opens the desktop conversation without recreating retired dock preferences', async () => { const user = userEvent.setup(); - const view = render( + render( ); await user.click(screen.getByRole('button', { name: 'Open board chat' })); - await user.click(screen.getByRole('button', { name: 'Dock bottom' })); expect(screen.getByLabelText('bottom panel').textContent).toBe('board-chat'); - expect(screen.getByLabelText('dock position').textContent).toBe('bottom'); - expect(window.localStorage.getItem('veritas.workbench.dockPosition')).toBe('bottom'); - - view.unmount(); - render( - - - - ); - expect(screen.getByLabelText('dock position').textContent).toBe('bottom'); + expect(window.localStorage.getItem('veritas.workbench.bottomPanelHeight')).toBeNull(); + expect(window.localStorage.getItem('veritas.workbench.dockPosition')).toBeNull(); }); it('closes transient chat layout with Escape or browser Back and restores focus', async () => { @@ -164,8 +146,6 @@ describe('desktop shell recovery', () => { expect(screen.getByLabelText('left rail').textContent).toBe('true'); expect(screen.getByLabelText('right rail').textContent).toBe('false'); expect(screen.getByLabelText('bottom panel').textContent).toBe('closed'); - expect(screen.getByLabelText('dock position').textContent).toBe('right'); - expect(screen.getByLabelText('bottom panel height').textContent).toBe('280'); expect(screen.getByLabelText('right panel width').textContent).toBe( String(DEFAULT_RIGHT_PANEL_WIDTH) ); diff --git a/web/src/__tests__/layout-chrome-mantine.test.tsx b/web/src/__tests__/layout-chrome-mantine.test.tsx index ca207390..b8bd401a 100644 --- a/web/src/__tests__/layout-chrome-mantine.test.tsx +++ b/web/src/__tests__/layout-chrome-mantine.test.tsx @@ -308,43 +308,40 @@ describe('layout chrome Mantine migration', () => { expect(activeItem.className).not.toContain('bg-primary/15'); }); - it.each(['right', 'bottom'] as const)( - 'toggles and switches the header chat controls with the %s dock', - async (dockPosition) => { - window.localStorage.setItem('veritas.workbench.dockPosition', dockPosition); - const user = userEvent.setup(); - renderDesktopHeaderChrome({ withBottomPanel: true }); + it('toggles and switches the header chat controls with the right dock', async () => { + const user = userEvent.setup(); + renderDesktopHeaderChrome({ withBottomPanel: true }); - const openBoard = screen.getByRole('button', { name: 'Open Board Chat' }); - const openSquad = screen.getByRole('button', { name: 'Open Squad Chat' }); - expect(openBoard.getAttribute('aria-pressed')).toBe('false'); - expect(openSquad.getAttribute('aria-pressed')).toBe('false'); + const openBoard = screen.getByRole('button', { name: 'Open Board Chat' }); + const openSquad = screen.getByRole('button', { name: 'Open Squad Chat' }); + expect(openBoard.getAttribute('aria-pressed')).toBe('false'); + expect(openSquad.getAttribute('aria-pressed')).toBe('false'); - await user.click(openBoard); - const closeBoard = screen.getByRole('button', { name: 'Close Board Chat' }); - expect(closeBoard.getAttribute('aria-pressed')).toBe('true'); - expect(screen.getByRole('button', { name: 'Switch to Squad Chat' })).toBeDefined(); + await user.click(openBoard); + const closeBoard = screen.getByRole('button', { name: 'Close Board Chat' }); + expect(closeBoard.getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByRole('button', { name: 'Switch to Squad Chat' })).toBeDefined(); - await user.click(closeBoard); - expect(screen.getByRole('button', { name: 'Open Board Chat' })).toBeDefined(); + await user.click(closeBoard); + expect(screen.getByRole('button', { name: 'Open Board Chat' })).toBeDefined(); - await user.click(screen.getByRole('button', { name: 'Open Board Chat' })); - await user.click(screen.getByRole('button', { name: 'Switch to Squad Chat' })); - const closeSquad = screen.getByRole('button', { name: 'Close Squad Chat' }); - expect(closeSquad.getAttribute('aria-pressed')).toBe('true'); - expect(screen.getByRole('button', { name: 'Switch to Board Chat' })).toBeDefined(); + await user.click(screen.getByRole('button', { name: 'Open Board Chat' })); + await user.click(screen.getByRole('button', { name: 'Switch to Squad Chat' })); + const closeSquad = screen.getByRole('button', { name: 'Close Squad Chat' }); + expect(closeSquad.getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByRole('button', { name: 'Switch to Board Chat' })).toBeDefined(); - await user.click(closeSquad); - await waitFor(() => { - const board = screen.getByRole('button', { name: 'Open Board Chat' }); - const squad = screen.getByRole('button', { name: 'Open Squad Chat' }); - expect([board, squad]).toContain(document.activeElement); - }); + await user.click(closeSquad); + await waitFor(() => { + const board = screen.getByRole('button', { name: 'Open Board Chat' }); + const squad = screen.getByRole('button', { name: 'Open Squad Chat' }); + expect([board, squad]).toContain(document.activeElement); + }); - await user.click(screen.getByRole('button', { name: 'Open Board Chat' })); - const dock = screen.getByRole('region', { name: `Workbench ${dockPosition} dock` }); - await user.click(within(dock).getByRole('button', { name: `Close ${dockPosition} dock` })); - expect(screen.getByRole('button', { name: 'Open Board Chat' })).toBeDefined(); - } - ); + await user.click(screen.getByRole('button', { name: 'Open Board Chat' })); + const dock = screen.getByRole('region', { name: 'Workbench right dock' }); + expect(within(dock).queryByRole('radiogroup', { name: 'Dock position' })).toBeNull(); + await user.click(within(dock).getByRole('button', { name: 'Close right dock' })); + expect(screen.getByRole('button', { name: 'Open Board Chat' })).toBeDefined(); + }); }); diff --git a/web/src/components/layout/DesktopBottomPanel.tsx b/web/src/components/layout/DesktopBottomPanel.tsx index a42c2d27..ebeb2c71 100644 --- a/web/src/components/layout/DesktopBottomPanel.tsx +++ b/web/src/components/layout/DesktopBottomPanel.tsx @@ -8,23 +8,13 @@ import { type WheelEvent, } from 'react'; import { ActionIcon, Group, SegmentedControl, Text } from '@mantine/core'; -import { - GripHorizontal, - GripVertical, - MessageSquare, - PanelBottomClose, - PanelRightClose, - Users, -} from 'lucide-react'; +import { GripVertical, MessageSquare, PanelRightClose, Users } from 'lucide-react'; import { - MAX_BOTTOM_PANEL_HEIGHT, MAX_RIGHT_PANEL_WIDTH, - MIN_BOTTOM_PANEL_HEIGHT, MIN_RIGHT_PANEL_WIDTH, useDesktopShell, type DesktopBottomPanel as DesktopBottomPanelId, - type DesktopDockPosition, } from './DesktopShellContext'; const ChatPanel = lazy(() => @@ -44,54 +34,30 @@ const PANEL_OPTIONS = [ { label: 'Squad Chat', value: 'squad-chat' }, ] satisfies Array<{ label: string; value: DesktopBottomPanelId }>; -const DOCK_OPTIONS = [ - { label: 'Right', value: 'right' }, - { label: 'Bottom', value: 'bottom' }, -] satisfies Array<{ label: string; value: DesktopDockPosition }>; - export function DesktopBottomPanel() { - const { - isDesktopClient, - bottomPanel, - dockPosition, - bottomPanelHeight, - rightPanelWidth, - setDockPosition, - setBottomPanelHeight, - setRightPanelWidth, - openBottomPanel, - closeBottomPanel, - } = useDesktopShell(); + const { bottomPanel, rightPanelWidth, setRightPanelWidth, openBottomPanel, closeBottomPanel } = + useDesktopShell(); const dragStateRef = useRef<{ startPosition: number; startSize: number } | null>(null); if (!bottomPanel) return null; const resizeBy = (delta: number) => { - if (dockPosition === 'right') { - setRightPanelWidth(rightPanelWidth + delta); - } else { - setBottomPanelHeight(bottomPanelHeight + delta); - } + setRightPanelWidth(rightPanelWidth + delta); }; const handleResizePointerDown = (event: PointerEvent) => { event.preventDefault(); dragStateRef.current = { - startPosition: dockPosition === 'right' ? event.clientX : event.clientY, - startSize: dockPosition === 'right' ? rightPanelWidth : bottomPanelHeight, + startPosition: event.clientX, + startSize: rightPanelWidth, }; event.currentTarget.setPointerCapture(event.pointerId); }; const handleResizePointerMove = (event: PointerEvent) => { if (!dragStateRef.current) return; - const currentPosition = dockPosition === 'right' ? event.clientX : event.clientY; - const delta = dragStateRef.current.startPosition - currentPosition; - if (dockPosition === 'right') { - setRightPanelWidth(dragStateRef.current.startSize + delta); - } else { - setBottomPanelHeight(dragStateRef.current.startSize + delta); - } + const delta = dragStateRef.current.startPosition - event.clientX; + setRightPanelWidth(dragStateRef.current.startSize + delta); }; const handleResizePointerEnd = (event: PointerEvent) => { @@ -102,28 +68,18 @@ export function DesktopBottomPanel() { }; const handleResizeKeyDown = (event: KeyboardEvent) => { - const growKey = dockPosition === 'right' ? 'ArrowLeft' : 'ArrowUp'; - const shrinkKey = dockPosition === 'right' ? 'ArrowRight' : 'ArrowDown'; - if (event.key === growKey) { + if (event.key === 'ArrowLeft') { event.preventDefault(); resizeBy(event.shiftKey ? 80 : 24); - } else if (event.key === shrinkKey) { + } else if (event.key === 'ArrowRight') { event.preventDefault(); resizeBy(event.shiftKey ? -80 : -24); } else if (event.key === 'Home') { event.preventDefault(); - if (dockPosition === 'right') { - setRightPanelWidth(MIN_RIGHT_PANEL_WIDTH); - } else { - setBottomPanelHeight(MIN_BOTTOM_PANEL_HEIGHT); - } + setRightPanelWidth(MIN_RIGHT_PANEL_WIDTH); } else if (event.key === 'End') { event.preventDefault(); - if (dockPosition === 'right') { - setRightPanelWidth(MAX_RIGHT_PANEL_WIDTH); - } else { - setBottomPanelHeight(MAX_BOTTOM_PANEL_HEIGHT); - } + setRightPanelWidth(MAX_RIGHT_PANEL_WIDTH); } }; @@ -132,16 +88,13 @@ export function DesktopBottomPanel() { event.stopPropagation(); }; - const orientationLabel = dockPosition === 'right' ? 'right dock' : 'bottom dock'; - return (
- {dockPosition === 'right' ? ( -