fix: retire bottom Workbench docking (#1390)

This commit is contained in:
Brad Groux 2026-09-03 11:25:56 -05:00 • committed by GitHub
parent c646932c85
commit 089e45a63f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 138 additions and 314 deletions

View file

@ -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)

View file

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

View file

@ -1755,8 +1755,8 @@ curl -X POST .../tasks/<id>/comments \
<div>
<h3>Bounded Chat Workbench</h3>
<p>
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.
</p>
</div>
</div>

View file

@ -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);

View file

@ -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 });

View file

@ -120,7 +120,7 @@ function DesktopAwareAppShell({
showDesktopOnboarding: boolean;
setShowDesktopOnboarding: (open: boolean) => void;
}) {
const { isDesktopClient, bottomPanel, dockPosition } = useDesktopShell();
const { isDesktopClient, bottomPanel } = useDesktopShell();
return (
<Box className="desktop-app-shell min-h-screen bg-background">
@ -132,10 +132,7 @@ function DesktopAwareAppShell({
<Suspense fallback={null}>
<PwaStatusBanner sessionExpiry={authStatus?.sessionExpiry} onRefreshAuth={refreshStatus} />
</Suspense>
<div
className="desktop-workbench-container"
data-dock-position={bottomPanel ? dockPosition : undefined}
>
<div className="desktop-workbench-container">
<div className="desktop-workbench">
<DesktopLeftSidebar />
<Box

View file

@ -16,18 +16,10 @@ function ShellProbe() {
<output aria-label="left rail">{String(shell.leftRailOpen)}</output>
<output aria-label="right rail">{String(shell.rightRailOpen)}</output>
<output aria-label="bottom panel">{shell.bottomPanel ?? 'closed'}</output>
<output aria-label="dock position">{shell.dockPosition}</output>
<output aria-label="bottom panel height">{shell.bottomPanelHeight}</output>
<output aria-label="right panel width">{shell.rightPanelWidth}</output>
<button type="button" onClick={() => shell.openBottomPanel('board-chat')}>
Open board chat
</button>
<button type="button" onClick={() => shell.setDockPosition('bottom')}>
Dock bottom
</button>
<button type="button" onClick={() => shell.setDockPosition('right')}>
Dock right
</button>
<button type="button" onClick={() => shell.setLeftRailOpen(false)}>
Close left rail
</button>
@ -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(
<DesktopShellProvider>
@ -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(
<DesktopShellProvider>
<ShellProbe />
</DesktopShellProvider>
);
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(
<DesktopShellProvider>
<ShellProbe />
</DesktopShellProvider>
);
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)
);

View file

@ -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();
});
});

View file

@ -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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
@ -102,28 +68,18 @@ export function DesktopBottomPanel() {
};
const handleResizeKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
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 (
<section
className={`workbench-chat-dock workbench-chat-dock--${dockPosition} bg-card`}
aria-label={`Workbench ${orientationLabel}`}
data-dock-position={dockPosition}
className="workbench-chat-dock workbench-chat-dock--right bg-card"
aria-label="Workbench right dock"
data-dock-position="right"
style={
{
'--workbench-bottom-panel-height': `${bottomPanelHeight}px`,
'--workbench-right-panel-width': `${rightPanelWidth}px`,
} as CSSProperties
}
@ -149,9 +102,9 @@ export function DesktopBottomPanel() {
<button
type="button"
className="workbench-chat-dock-resizer desktop-no-drag"
aria-label={`Resize ${orientationLabel}`}
aria-orientation={dockPosition === 'right' ? 'vertical' : 'horizontal'}
title={`Drag to resize ${orientationLabel}`}
aria-label="Resize right dock"
aria-orientation="vertical"
title="Drag to resize right dock"
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerEnd}
@ -159,11 +112,7 @@ export function DesktopBottomPanel() {
onKeyDown={handleResizeKeyDown}
onWheel={handleResizeWheel}
>
{dockPosition === 'right' ? (
<GripVertical className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<GripHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
)}
<GripVertical className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<div className="desktop-no-drag shrink-0 space-y-2 border-b border-border px-3 py-2">
<Group justify="space-between" wrap="nowrap">
@ -178,28 +127,15 @@ export function DesktopBottomPanel() {
</Text>
</Group>
<Group gap={4} wrap="nowrap">
{isDesktopClient && (
<SegmentedControl
size="xs"
value={dockPosition}
onChange={(value) => setDockPosition(value as DesktopDockPosition)}
data={DOCK_OPTIONS}
aria-label="Dock position"
/>
)}
<ActionIcon
variant="subtle"
color="gray"
size={30}
onClick={closeBottomPanel}
aria-label={`Close ${orientationLabel}`}
title={`Close ${orientationLabel}`}
aria-label="Close right dock"
title="Close right dock"
>
{dockPosition === 'right' ? (
<PanelRightClose className="h-4 w-4" aria-hidden="true" />
) : (
<PanelBottomClose className="h-4 w-4" aria-hidden="true" />
)}
<PanelRightClose className="h-4 w-4" aria-hidden="true" />
</ActionIcon>
</Group>
</Group>

View file

@ -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<HTMLElement | null>(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<DesktopBottomPanel | null>(null);
const [dockPosition, setDockPositionState] = useState<DesktopDockPosition>(() =>
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,

View file

@ -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<Set<LazyPanel>>(() => 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 && (
<Group gap={4} wrap="nowrap" className="desktop-no-drag">
{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' ? (
<PanelBottomClose className="h-4 w-4" aria-hidden="true" />
) : bottomPanel ? (
{bottomPanel ? (
<PanelRightClose className="h-4 w-4" aria-hidden="true" />
) : dockPosition === 'bottom' ? (
<PanelBottom className="h-4 w-4" aria-hidden="true" />
) : (
<PanelRight className="h-4 w-4" aria-hidden="true" />
)}

View file

@ -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));