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

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

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
|  |  |
- **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}
shell.openBottomPanel('board-chat')}>
Open board chat
- shell.setDockPosition('bottom')}>
- Dock bottom
-
- shell.setDockPosition('right')}>
- Dock right
-
shell.setLeftRailOpen(false)}>
Close left rail
@@ -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' ? (
-
- ) : (
-
- )}
+
@@ -178,28 +127,15 @@ export function DesktopBottomPanel() {
- {isDesktopClient && (
- setDockPosition(value as DesktopDockPosition)}
- data={DOCK_OPTIONS}
- aria-label="Dock position"
- />
- )}
- {dockPosition === 'right' ? (
-
- ) : (
-
- )}
+
diff --git a/web/src/components/layout/DesktopShellContext.tsx b/web/src/components/layout/DesktopShellContext.tsx
index 58472233..5b25c514 100644
--- a/web/src/components/layout/DesktopShellContext.tsx
+++ b/web/src/components/layout/DesktopShellContext.tsx
@@ -8,23 +8,17 @@ import {
useState,
type ReactNode,
} from 'react';
-import { useMediaQuery } from '@mantine/hooks';
export type DesktopBottomPanel = 'board-chat' | 'squad-chat';
-export type DesktopDockPosition = 'right' | 'bottom';
interface DesktopShellContextValue {
isDesktopClient: boolean;
leftRailOpen: boolean;
rightRailOpen: boolean;
bottomPanel: DesktopBottomPanel | null;
- dockPosition: DesktopDockPosition;
- bottomPanelHeight: number;
rightPanelWidth: number;
setLeftRailOpen: (open: boolean) => void;
setRightRailOpen: (open: boolean) => void;
- setDockPosition: (position: DesktopDockPosition) => void;
- setBottomPanelHeight: (height: number) => void;
setRightPanelWidth: (width: number) => void;
openBottomPanel: (panel: DesktopBottomPanel) => void;
closeBottomPanel: () => void;
@@ -38,13 +32,8 @@ const BOTTOM_PANEL_HEIGHT_STORAGE_KEY = 'veritas.workbench.bottomPanelHeight';
const DOCK_POSITION_STORAGE_KEY = 'veritas.workbench.dockPosition';
const RIGHT_PANEL_WIDTH_STORAGE_KEY = 'veritas.workbench.rightPanelWidth';
const BOTTOM_PANEL_HISTORY_STATE_KEY = 'veritasBottomPanel';
-const BOTTOM_PANEL_VIEWPORT_RESERVE = 320;
const RIGHT_PANEL_VIEWPORT_RESERVE = 520;
-const COMPACT_BOTTOM_PANEL_HEIGHT = 200;
const COMPACT_RIGHT_PANEL_WIDTH = 280;
-export const DEFAULT_BOTTOM_PANEL_HEIGHT = 340;
-export const MIN_BOTTOM_PANEL_HEIGHT = 320;
-export const MAX_BOTTOM_PANEL_HEIGHT = 640;
export const DEFAULT_RIGHT_PANEL_WIDTH = 420;
export const MIN_RIGHT_PANEL_WIDTH = 320;
export const MAX_RIGHT_PANEL_WIDTH = 640;
@@ -54,13 +43,9 @@ const DEFAULT_CONTEXT: DesktopShellContextValue = {
leftRailOpen: false,
rightRailOpen: false,
bottomPanel: null,
- dockPosition: 'right',
- bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT,
rightPanelWidth: DEFAULT_RIGHT_PANEL_WIDTH,
setLeftRailOpen: () => undefined,
setRightRailOpen: () => undefined,
- setDockPosition: () => undefined,
- setBottomPanelHeight: () => undefined,
setRightPanelWidth: () => undefined,
openBottomPanel: () => undefined,
closeBottomPanel: () => undefined,
@@ -88,19 +73,6 @@ function readStoredBoolean(key: string, fallback: boolean): boolean {
}
}
-function clampBottomPanelHeight(height: number): number {
- const finiteHeight = Number.isFinite(height) ? height : DEFAULT_BOTTOM_PANEL_HEIGHT;
- const viewportMax =
- typeof window === 'undefined'
- ? MAX_BOTTOM_PANEL_HEIGHT
- : Math.min(
- MAX_BOTTOM_PANEL_HEIGHT,
- Math.max(COMPACT_BOTTOM_PANEL_HEIGHT, window.innerHeight - BOTTOM_PANEL_VIEWPORT_RESERVE)
- );
- const viewportMin = Math.min(MIN_BOTTOM_PANEL_HEIGHT, viewportMax);
- return Math.min(viewportMax, Math.max(viewportMin, finiteHeight));
-}
-
function clampRightPanelWidth(width: number): number {
const finiteWidth = Number.isFinite(width) ? width : DEFAULT_RIGHT_PANEL_WIDTH;
const viewportMax =
@@ -114,21 +86,6 @@ function clampRightPanelWidth(width: number): number {
return Math.min(viewportMax, Math.max(viewportMin, finiteWidth));
}
-function readStoredBottomPanelHeight(): number {
- if (typeof window === 'undefined') return DEFAULT_BOTTOM_PANEL_HEIGHT;
-
- try {
- const stored = window.localStorage.getItem(BOTTOM_PANEL_HEIGHT_STORAGE_KEY);
- if (stored === null) return clampBottomPanelHeight(DEFAULT_BOTTOM_PANEL_HEIGHT);
- const value = Number(stored);
- return Number.isFinite(value) && value > 0
- ? clampBottomPanelHeight(value)
- : clampBottomPanelHeight(DEFAULT_BOTTOM_PANEL_HEIGHT);
- } catch {
- return DEFAULT_BOTTOM_PANEL_HEIGHT;
- }
-}
-
function readStoredRightPanelWidth(): number {
if (typeof window === 'undefined') return DEFAULT_RIGHT_PANEL_WIDTH;
@@ -144,19 +101,6 @@ function readStoredRightPanelWidth(): number {
}
}
-function readStoredDockPosition(): DesktopDockPosition {
- if (typeof window === 'undefined') return 'right';
-
- try {
- const stored = window.localStorage.getItem(DOCK_POSITION_STORAGE_KEY);
- if (stored === 'right' || stored === 'bottom') return stored;
- if (stored !== null) window.localStorage.removeItem(DOCK_POSITION_STORAGE_KEY);
- } catch {
- // Use the safe desktop default when storage is unavailable.
- }
- return 'right';
-}
-
function writeStoredValue(key: string, value: string): void {
try {
window.localStorage.setItem(key, value);
@@ -179,8 +123,6 @@ function isBottomPanel(value: unknown): value is DesktopBottomPanel {
export function DesktopShellProvider({ children }: { children: ReactNode }) {
const desktopClient = isDesktopClient();
- const supportsWorkbenchPanel = useMediaQuery('(min-width: 768px)', false);
- const canUseBottomPanel = desktopClient || supportsWorkbenchPanel;
const panelTriggerRef = useRef
(null);
const [leftRailOpen, setLeftRailOpenState] = useState(() =>
readStoredBoolean(LEFT_RAIL_STORAGE_KEY, true)
@@ -189,12 +131,6 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
readStoredBoolean(RIGHT_RAIL_STORAGE_KEY, false)
);
const [bottomPanel, setBottomPanel] = useState(null);
- const [dockPosition, setDockPositionState] = useState(() =>
- readStoredDockPosition()
- );
- const [bottomPanelHeight, setBottomPanelHeightState] = useState(() =>
- readStoredBottomPanelHeight()
- );
const [rightPanelWidth, setRightPanelWidthState] = useState(() => readStoredRightPanelWidth());
const setLeftRailOpen = useCallback(
@@ -213,20 +149,6 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
[desktopClient]
);
- const setDockPosition = useCallback(
- (position: DesktopDockPosition) => {
- setDockPositionState(position);
- if (desktopClient) writeStoredValue(DOCK_POSITION_STORAGE_KEY, position);
- },
- [desktopClient]
- );
-
- const setBottomPanelHeight = useCallback((height: number) => {
- const next = clampBottomPanelHeight(height);
- setBottomPanelHeightState(next);
- writeStoredValue(BOTTOM_PANEL_HEIGHT_STORAGE_KEY, String(next));
- }, []);
-
const setRightPanelWidth = useCallback((width: number) => {
const next = clampRightPanelWidth(width);
setRightPanelWidthState(next);
@@ -290,8 +212,6 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
setLeftRailOpenState(true);
setRightRailOpenState(false);
setBottomPanel(null);
- setDockPositionState('right');
- setBottomPanelHeightState(clampBottomPanelHeight(DEFAULT_BOTTOM_PANEL_HEIGHT));
setRightPanelWidthState(clampRightPanelWidth(DEFAULT_RIGHT_PANEL_WIDTH));
removeStoredValue(LEFT_RAIL_STORAGE_KEY);
removeStoredValue(RIGHT_RAIL_STORAGE_KEY);
@@ -317,6 +237,8 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
useEffect(() => {
removeStoredValue(BOTTOM_PANEL_STORAGE_KEY);
+ removeStoredValue(BOTTOM_PANEL_HEIGHT_STORAGE_KEY);
+ removeStoredValue(DOCK_POSITION_STORAGE_KEY);
}, []);
useEffect(() => {
@@ -331,13 +253,6 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
if (!isBottomPanel(panel)) restorePanelFocus();
};
const handleResize = () => {
- setBottomPanelHeightState((current) => {
- const next = clampBottomPanelHeight(current);
- if (next !== current) {
- writeStoredValue(BOTTOM_PANEL_HEIGHT_STORAGE_KEY, String(next));
- }
- return next;
- });
setRightPanelWidthState((current) => {
const next = clampRightPanelWidth(current);
if (next !== current) {
@@ -377,14 +292,10 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
isDesktopClient: desktopClient,
leftRailOpen: desktopClient ? leftRailOpen : false,
rightRailOpen: desktopClient ? rightRailOpen : false,
- bottomPanel: canUseBottomPanel ? bottomPanel : null,
- dockPosition: desktopClient ? dockPosition : 'bottom',
- bottomPanelHeight,
+ bottomPanel: desktopClient ? bottomPanel : null,
rightPanelWidth,
setLeftRailOpen,
setRightRailOpen,
- setDockPosition,
- setBottomPanelHeight,
setRightPanelWidth,
openBottomPanel,
closeBottomPanel,
@@ -393,16 +304,11 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
[
bottomPanel,
closeBottomPanel,
- canUseBottomPanel,
desktopClient,
- dockPosition,
- bottomPanelHeight,
leftRailOpen,
openBottomPanel,
rightPanelWidth,
rightRailOpen,
- setDockPosition,
- setBottomPanelHeight,
setLeftRailOpen,
setRightPanelWidth,
setRightRailOpen,
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index c2cb7eb8..00f9bf0c 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -30,8 +30,6 @@ import {
ListOrdered,
MoreHorizontal,
MessageSquare,
- PanelBottom,
- PanelBottomClose,
PanelLeft,
PanelLeftClose,
PanelRight,
@@ -147,7 +145,6 @@ export function Header() {
const [squadChatOpen, setSquadChatOpen] = useState(false);
const [loadedPanels, setLoadedPanels] = useState>(() => new Set());
const isCompactHeader = useMediaQuery('(max-width: 639px)', false);
- const supportsWorkbenchPanel = useMediaQuery('(min-width: 768px)', false);
const { setOpenCreateDialog, setOpenChatPanel } = useKeyboard();
const { view, setView, navigateToTask } = useView();
const { data: backlogCount = 0 } = useBacklogCount();
@@ -160,13 +157,12 @@ export function Header() {
leftRailOpen,
rightRailOpen,
bottomPanel,
- dockPosition,
setLeftRailOpen,
setRightRailOpen,
openBottomPanel,
toggleBottomPanel,
} = useDesktopShell();
- const usesWorkbenchChat = isDesktopClient || supportsWorkbenchPanel;
+ const usesWorkbenchChat = isDesktopClient;
const boardChatActive = usesWorkbenchChat && bottomPanel === 'board-chat';
const squadChatActive = usesWorkbenchChat && bottomPanel === 'squad-chat';
const boardChatAction = !usesWorkbenchChat
@@ -205,13 +201,13 @@ export function Header() {
}, [markPanelLoaded]);
const openChatPanel = useCallback(() => {
- if (isDesktopClient || supportsWorkbenchPanel) {
+ if (isDesktopClient) {
openBottomPanel('board-chat');
return;
}
markPanelLoaded('chat');
setChatOpen(true);
- }, [isDesktopClient, markPanelLoaded, openBottomPanel, supportsWorkbenchPanel]);
+ }, [isDesktopClient, markPanelLoaded, openBottomPanel]);
const toggleChatPanel = useCallback(() => {
if (usesWorkbenchChat) {
@@ -231,13 +227,13 @@ export function Header() {
);
const openSquadChatPanel = useCallback(() => {
- if (isDesktopClient || supportsWorkbenchPanel) {
+ if (isDesktopClient) {
openBottomPanel('squad-chat');
return;
}
markPanelLoaded('squadChat');
setSquadChatOpen(true);
- }, [isDesktopClient, markPanelLoaded, openBottomPanel, supportsWorkbenchPanel]);
+ }, [isDesktopClient, markPanelLoaded, openBottomPanel]);
const toggleSquadChatPanel = useCallback(() => {
if (usesWorkbenchChat) {
@@ -421,7 +417,7 @@ export function Header() {
aria-label="Board actions"
className="min-w-0 shrink-0"
>
- {(isDesktopClient || supportsWorkbenchPanel) && (
+ {isDesktopClient && (
{isDesktopClient && (
<>
@@ -462,16 +458,12 @@ export function Header() {
color={bottomPanel ? 'veritas' : 'gray'}
size={32}
onClick={() => toggleBottomPanel('board-chat')}
- aria-label={bottomPanel ? `Close ${dockPosition} chat dock` : 'Open chat dock'}
+ aria-label={bottomPanel ? 'Close right chat dock' : 'Open chat dock'}
aria-pressed={Boolean(bottomPanel)}
- title={bottomPanel ? `Close ${dockPosition} chat dock` : 'Open chat dock'}
+ title={bottomPanel ? 'Close right chat dock' : 'Open chat dock'}
>
- {bottomPanel && dockPosition === 'bottom' ? (
-
- ) : bottomPanel ? (
+ {bottomPanel ? (
- ) : dockPosition === 'bottom' ? (
-
) : (
)}
diff --git a/web/src/globals.css b/web/src/globals.css
index c126bb49..6f4523ee 100644
--- a/web/src/globals.css
+++ b/web/src/globals.css
@@ -234,13 +234,19 @@ html[data-client='desktop'],
html[data-client='desktop'] body,
html[data-client='desktop'] #root {
height: 100%;
+ min-height: 0;
+ max-height: 100%;
overflow: hidden;
+ overscroll-behavior: none;
}
html[data-client='desktop'] .desktop-app-shell {
display: flex;
height: 100vh;
+ height: 100dvh;
min-height: 0;
+ max-height: 100dvh;
+ width: 100%;
flex-direction: column;
overflow: hidden;
/* Keep tall board and drag descendants inside the app's internal scrollers.
@@ -286,19 +292,11 @@ html[data-client='desktop'] .desktop-workbench-container {
min-height: 0;
min-width: 0;
flex: 1 1 auto;
+ flex-direction: row;
overflow: hidden;
overscroll-behavior: none;
}
-html[data-client='desktop'] .desktop-workbench-container[data-dock-position='right'] {
- flex-direction: row;
-}
-
-html[data-client='desktop'] .desktop-workbench-container[data-dock-position='bottom'],
-html[data-client='desktop'] .desktop-workbench-container:not([data-dock-position]) {
- flex-direction: column;
-}
-
html[data-client='desktop'] .desktop-workbench {
display: flex;
min-height: 0;
@@ -329,25 +327,8 @@ html[data-client='desktop'] .desktop-main-content {
overscroll-behavior: contain;
}
-html:not([data-client='desktop']) .workbench-chat-dock {
- position: sticky;
- bottom: 0;
- z-index: 35;
- height: var(--workbench-bottom-panel-height, 340px);
- max-height: min(max(200px, calc(100vh - 320px)), 640px);
- flex: 0 0 var(--workbench-bottom-panel-height, 340px);
- box-shadow: 0 -12px 32px color-mix(in oklch, var(--foreground) 10%, transparent);
-}
-
-html[data-client='desktop'] .workbench-chat-dock--bottom {
- height: var(--workbench-bottom-panel-height, 340px);
- min-height: 0;
- max-height: min(max(200px, calc(100vh - 320px)), 640px);
- flex: 0 0 var(--workbench-bottom-panel-height, 340px);
- border-top: 1px solid var(--border);
-}
-
html[data-client='desktop'] .workbench-chat-dock--right {
+ position: relative;
width: var(--workbench-right-panel-width, 420px);
min-width: 0;
max-width: min(max(280px, calc(100vw - 520px)), 640px);
@@ -367,13 +348,6 @@ html[data-client='desktop'] .workbench-chat-dock--right {
overscroll-behavior: none;
}
-.workbench-chat-dock--bottom .workbench-chat-dock-resizer {
- height: 10px;
- width: 100%;
- border-bottom-width: 1px;
- cursor: ns-resize;
-}
-
.workbench-chat-dock--right .workbench-chat-dock-resizer {
position: absolute;
z-index: 2;
@@ -384,10 +358,6 @@ html[data-client='desktop'] .workbench-chat-dock--right {
cursor: ew-resize;
}
-.workbench-chat-dock--right {
- position: relative;
-}
-
.workbench-chat-dock-resizer:hover,
.workbench-chat-dock-resizer:focus-visible {
background: color-mix(in oklch, var(--primary) 12%, var(--card));