dock.setOpen(false)} />}
+
+ >
+ );
+}
diff --git a/apps/agent-forge/src/shell/__tests__/Dock.test.tsx b/apps/agent-forge/src/shell/__tests__/Dock.test.tsx
new file mode 100644
index 000000000..dcd41d2df
--- /dev/null
+++ b/apps/agent-forge/src/shell/__tests__/Dock.test.tsx
@@ -0,0 +1,185 @@
+import { act, type ReactNode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const appState = vi.hoisted(() => ({ connected: true, agents: [] }));
+vi.mock('../../state/AppState', () => ({ useAppState: () => appState }));
+
+import { AppShell } from '../AppShell';
+import { useDock } from '../Dock';
+import { DOCK_OPEN_KEY, DOCK_WIDTH_KEY } from '../dockState';
+import { ToastProvider } from '../../ui/Toast';
+import { render } from '../../ui/__tests__/render';
+import { installMemoryStorage } from '../../ui/__tests__/memoryStorage';
+
+// jsdom may not implement pointer capture; the Dock guards the calls, but stub
+// them so a jsdom without the API still exercises the drag path.
+if (typeof HTMLElement.prototype.setPointerCapture !== 'function') {
+ HTMLElement.prototype.setPointerCapture = () => {};
+ HTMLElement.prototype.releasePointerCapture = () => {};
+}
+
+function stubNarrow(narrow: boolean) {
+ window.matchMedia = ((query: string) => ({
+ matches: narrow,
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ })) as unknown as typeof window.matchMedia;
+}
+
+function setViewport(w: number) {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: w });
+}
+
+function mount(dock?: ReactNode) {
+ return render(
+
+
+ screen
+
+ ,
+ );
+}
+
+const shellEl = (c: HTMLElement) => c.querySelector('.app-shell') as HTMLElement;
+const aside = (c: HTMLElement) => c.querySelector('aside#app-dock') as HTMLElement | null;
+const separator = (c: HTMLElement) => c.querySelector('[role="separator"]') as HTMLElement;
+
+const key = (el: Element, k: string) =>
+ act(() => {
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ });
+
+const pointer = (el: Element, type: string, clientX: number) =>
+ act(() => {
+ el.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true, cancelable: true }));
+ });
+
+beforeEach(() => {
+ installMemoryStorage();
+ stubNarrow(false);
+ setViewport(1600);
+ window.location.hash = '#/traces';
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('Dock docked', () => {
+ it('opens as a third grid column with the stored open state and default width', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount(
x
);
+ expect(shellEl(c).getAttribute('data-dock')).toBe('open');
+ expect(shellEl(c).getAttribute('style')).toContain('--dock-width: 490px');
+ expect(aside(c)?.getAttribute('aria-label')).toBe('Agent Author');
+ expect(c.querySelector('#dock-content')).not.toBeNull();
+ const sep = separator(c);
+ expect(sep.getAttribute('aria-valuenow')).toBe('490');
+ expect(sep.getAttribute('aria-valuemin')).toBe('360');
+ expect(sep.getAttribute('aria-valuemax')).toBe('640');
+ });
+
+ it('renders the shell exactly as before when no dock prop is passed', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount();
+ expect(aside(c)).toBeNull();
+ expect(shellEl(c).hasAttribute('data-dock')).toBe(false);
+ expect(shellEl(c).style.getPropertyValue('--dock-width')).toBe('');
+ expect(c.querySelector('#screen')).not.toBeNull();
+ });
+
+ it('restores a stored width', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ window.localStorage.setItem(DOCK_WIDTH_KEY, '600');
+ const c = mount(
x
);
+ expect(separator(c).getAttribute('aria-valuenow')).toBe('600');
+ expect(shellEl(c).getAttribute('style')).toContain('--dock-width: 600px');
+ });
+
+ it('resizes by 16px with the arrow keys and jumps to the limits with Home/End', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount(
x
);
+ const sep = separator(c);
+ key(sep, 'ArrowLeft');
+ expect(sep.getAttribute('aria-valuenow')).toBe('506');
+ expect(window.localStorage.getItem(DOCK_WIDTH_KEY)).toBe('506');
+ key(sep, 'ArrowRight');
+ expect(sep.getAttribute('aria-valuenow')).toBe('490');
+ key(sep, 'Home');
+ expect(sep.getAttribute('aria-valuenow')).toBe('360');
+ expect(window.localStorage.getItem(DOCK_WIDTH_KEY)).toBe('360');
+ key(sep, 'End');
+ expect(sep.getAttribute('aria-valuenow')).toBe('640');
+ expect(window.localStorage.getItem(DOCK_WIDTH_KEY)).toBe('640');
+ });
+
+ it('drags the handle to resize live and persists the width on release', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount(
x
);
+ const sep = separator(c);
+ pointer(sep, 'pointerdown', 1000);
+ expect(sep.hasAttribute('data-dragging')).toBe(true);
+ expect(shellEl(c).hasAttribute('data-dock-resizing')).toBe(true);
+ pointer(sep, 'pointermove', 900);
+ expect(sep.getAttribute('aria-valuenow')).toBe('590');
+ expect(shellEl(c).getAttribute('style')).toContain('--dock-width: 590px');
+ expect(window.localStorage.getItem(DOCK_WIDTH_KEY)).toBeNull(); // nothing stored mid-drag
+ pointer(sep, 'pointerup', 900);
+ expect(sep.hasAttribute('data-dragging')).toBe(false);
+ expect(shellEl(c).hasAttribute('data-dock-resizing')).toBe(false);
+ expect(window.localStorage.getItem(DOCK_WIDTH_KEY)).toBe('590');
+ });
+
+ it('closes on Escape inside the panel unless a child handler stopped it', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount(
x
);
+ key(c.querySelector('#dock-content')!, 'Escape');
+ expect(aside(c)).toBeNull();
+ expect(window.localStorage.getItem(DOCK_OPEN_KEY)).toBe('0');
+
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c2 = mount(
+
e.preventDefault()}>
+ x
+
,
+ );
+ key(c2.querySelector('#dock-content')!, 'Escape');
+ expect(aside(c2)).not.toBeNull();
+ });
+});
+
+describe('Dock narrow sheet', () => {
+ it('opens as a right-side dialog sheet over a scrim; scrim click and Escape close it', () => {
+ stubNarrow(true);
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c = mount(
x
);
+ const sheet = aside(c);
+ expect(sheet?.className).toContain('dock-sheet');
+ expect(sheet?.getAttribute('role')).toBe('dialog');
+ expect(sheet?.getAttribute('aria-modal')).toBe('true');
+ expect(c.querySelector('[role="separator"]')).toBeNull();
+ const scrim = c.querySelector('.dock-scrim') as HTMLElement;
+ expect(scrim).not.toBeNull();
+ expect(scrim.getAttribute('aria-hidden')).toBe('true');
+ act(() => scrim.click());
+ expect(aside(c)).toBeNull();
+ expect(window.localStorage.getItem(DOCK_OPEN_KEY)).toBe('0');
+
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ const c2 = mount(
x
);
+ key(c2.querySelector('#dock-content')!, 'Escape');
+ expect(aside(c2)).toBeNull();
+ expect(window.localStorage.getItem(DOCK_OPEN_KEY)).toBe('0');
+ });
+});
+
+describe('useDock', () => {
+ it('throws outside a DockProvider', () => {
+ function Probe() {
+ useDock();
+ return null;
+ }
+ expect(() => render(
)).toThrow('useDock must be used inside a
.');
+ });
+});
diff --git a/apps/agent-forge/src/shell/__tests__/dockState.test.ts b/apps/agent-forge/src/shell/__tests__/dockState.test.ts
new file mode 100644
index 000000000..83f225563
--- /dev/null
+++ b/apps/agent-forge/src/shell/__tests__/dockState.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { breakStorage, installMemoryStorage } from '../../ui/__tests__/memoryStorage';
+import {
+ DOCK_DEFAULT_WIDTH,
+ DOCK_MAX_VIEWPORT_FRACTION,
+ DOCK_MAX_WIDTH,
+ DOCK_MIN_WIDTH,
+ DOCK_OPEN_KEY,
+ DOCK_WIDTH_KEY,
+ clampDockWidth,
+ dockMaxWidth,
+ readDockOpen,
+ readDockWidth,
+} from '../dockState';
+
+beforeEach(() => {
+ installMemoryStorage();
+});
+
+describe('clampDockWidth', () => {
+ it('clamps to the 360px floor, the 640px ceiling and 45vw, and rounds', () => {
+ expect(clampDockWidth(100, 1600)).toBe(DOCK_MIN_WIDTH); // 360
+ expect(clampDockWidth(9999, 1600)).toBe(DOCK_MAX_WIDTH); // 640
+ expect(clampDockWidth(9999, 1000)).toBe(450);
+ expect(clampDockWidth(500.6, 1600)).toBe(501);
+ expect(clampDockWidth(NaN, 1600)).toBe(DOCK_DEFAULT_WIDTH); // 490
+ });
+});
+
+describe('dockMaxWidth', () => {
+ it('is min(640, floor(45% of vw)) and never below 360', () => {
+ expect(dockMaxWidth(700)).toBe(DOCK_MIN_WIDTH); // 360 even though 45vw is less
+ expect(dockMaxWidth(2000)).toBe(DOCK_MAX_WIDTH);
+ expect(dockMaxWidth(1000)).toBe(Math.floor(1000 * DOCK_MAX_VIEWPORT_FRACTION)); // 450
+ });
+});
+
+describe('readDockWidth', () => {
+ it('returns the stored width clamped to the viewport', () => {
+ window.localStorage.setItem(DOCK_WIDTH_KEY, '9999');
+ expect(readDockWidth(1600)).toBe(DOCK_MAX_WIDTH);
+ });
+
+ it('falls back to the default on a non-numeric value or broken storage', () => {
+ window.localStorage.setItem(DOCK_WIDTH_KEY, 'abc');
+ expect(readDockWidth(1600)).toBe(DOCK_DEFAULT_WIDTH);
+ breakStorage();
+ expect(readDockWidth(1600)).toBe(DOCK_DEFAULT_WIDTH);
+ });
+});
+
+describe('readDockOpen', () => {
+ it('is true only for a stored "1"', () => {
+ window.localStorage.setItem(DOCK_OPEN_KEY, '1');
+ expect(readDockOpen()).toBe(true);
+ window.localStorage.setItem(DOCK_OPEN_KEY, '0');
+ expect(readDockOpen()).toBe(false);
+ });
+
+ it('is false when nothing is stored or storage throws', () => {
+ expect(readDockOpen()).toBe(false);
+ breakStorage();
+ expect(readDockOpen()).toBe(false);
+ });
+});
diff --git a/apps/agent-forge/src/shell/dock.css b/apps/agent-forge/src/shell/dock.css
new file mode 100644
index 000000000..380be46ac
--- /dev/null
+++ b/apps/agent-forge/src/shell/dock.css
@@ -0,0 +1,95 @@
+/*
+ * Right-hand docked panel (the Agent Author's home, filled in by AA15).
+ * Docked it is a third app-shell grid column that pushes the screen narrower;
+ * below 860px it is a right-side sheet over a scrim.
+ * The open/close width animation comes from .app-shell's grid-template-columns
+ * transition in shell.css (already disabled under reduced motion there).
+ */
+.app-shell[data-dock='open'] {
+ grid-template-columns: 232px minmax(0, 1fr) var(--dock-width);
+}
+.app-shell[data-nav='collapsed'][data-dock='open'] {
+ grid-template-columns: 56px minmax(0, 1fr) var(--dock-width);
+}
+.app-shell[data-dock-resizing] {
+ transition: none;
+}
+.dock {
+ position: relative;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ padding: var(--space-2) var(--space-2) var(--space-2) 0;
+}
+.dock-card {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+}
+.dock-resize {
+ position: absolute;
+ top: var(--space-2);
+ bottom: var(--space-2);
+ left: calc(-1 * var(--space-2));
+ width: var(--space-2);
+ cursor: col-resize;
+ border-radius: var(--radius-sm);
+ z-index: 1;
+ transition: background-color var(--dur-fast) var(--ease);
+}
+.dock-resize:hover {
+ background: color-mix(in srgb, var(--accent) 20%, transparent);
+}
+.dock-resize[data-dragging] {
+ background: color-mix(in srgb, var(--accent) 30%, transparent);
+}
+.dock-resize:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* narrow: a right sheet over the screen */
+.dock-sheet {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 50;
+ width: min(100vw, 560px);
+ padding: 0;
+}
+.dock-sheet .dock-card {
+ border-radius: 0;
+ border-width: 0 0 0 1px;
+ box-shadow: var(--shadow-lg);
+}
+.dock-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 45;
+ background: var(--scrim);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .dock-sheet {
+ animation: dock-in var(--dur) var(--ease);
+ }
+}
+@keyframes dock-in {
+ from {
+ transform: translateX(24px);
+ opacity: 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .dock-resize {
+ transition: none;
+ }
+}
diff --git a/apps/agent-forge/src/shell/dockState.ts b/apps/agent-forge/src/shell/dockState.ts
new file mode 100644
index 000000000..70e75a4cf
--- /dev/null
+++ b/apps/agent-forge/src/shell/dockState.ts
@@ -0,0 +1,38 @@
+import { readStored } from '../components/storedValue';
+
+/** Width limits of the docked panel, px. The max is also capped at 45% of the viewport. */
+export const DOCK_MIN_WIDTH = 360;
+export const DOCK_MAX_WIDTH = 640;
+export const DOCK_DEFAULT_WIDTH = 490;
+export const DOCK_MAX_VIEWPORT_FRACTION = 0.45;
+/** Keyboard resize step, px. */
+export const DOCK_KEY_STEP = 16;
+/** localStorage keys: width as an integer string; open as "1" | "0". */
+export const DOCK_WIDTH_KEY = 'forge.dock.width';
+export const DOCK_OPEN_KEY = 'forge.dock.open';
+
+/** The largest allowed width for this viewport: min(640, floor(45% of vw)), never below 360. */
+export function dockMaxWidth(viewportWidth: number): number {
+ return Math.max(
+ DOCK_MIN_WIDTH,
+ Math.min(DOCK_MAX_WIDTH, Math.floor(viewportWidth * DOCK_MAX_VIEWPORT_FRACTION)),
+ );
+}
+
+/** `px` rounded and clamped to [DOCK_MIN_WIDTH, dockMaxWidth(viewportWidth)]; NaN → DOCK_DEFAULT_WIDTH. */
+export function clampDockWidth(px: number, viewportWidth: number): number {
+ if (!Number.isFinite(px)) return DOCK_DEFAULT_WIDTH;
+ return Math.min(Math.max(Math.round(px), DOCK_MIN_WIDTH), dockMaxWidth(viewportWidth));
+}
+
+/** Stored width (clamped) or the default; storage errors read as "nothing stored". */
+export function readDockWidth(viewportWidth: number): number {
+ const stored = readStored(DOCK_WIDTH_KEY);
+ if (stored === undefined) return DOCK_DEFAULT_WIDTH;
+ return clampDockWidth(Number.parseInt(stored, 10), viewportWidth);
+}
+
+/** Whether the dock was left open ("1" → true, anything else → false). */
+export function readDockOpen(): boolean {
+ return readStored(DOCK_OPEN_KEY) === '1';
+}
diff --git a/apps/agent-forge/src/shell/shell.css b/apps/agent-forge/src/shell/shell.css
index 797b4d304..d14496c6c 100644
--- a/apps/agent-forge/src/shell/shell.css
+++ b/apps/agent-forge/src/shell/shell.css
@@ -9,6 +9,7 @@
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
+ background: var(--nav-bg);
transition: grid-template-columns var(--dur) var(--ease);
}
.app-shell[data-nav='collapsed'] {
@@ -19,6 +20,12 @@
min-height: 0;
display: flex;
flex-direction: column;
+ margin: var(--space-2) var(--space-2) var(--space-2) 0;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
}
.app-shell-main > .app {
flex: 1;
@@ -34,8 +41,8 @@
}
.nav-rail {
- background: var(--surface);
- border-right: 1px solid var(--border);
+ background: var(--nav-bg);
+ border-right: 0;
display: flex;
flex-direction: column;
padding: var(--space-3) var(--space-2);
@@ -131,13 +138,15 @@
overflow: hidden;
text-overflow: ellipsis;
}
+/* Nav items sit on --nav-bg: --surface-2 is too close to it in light mode, so hover/active use --surface there. */
.nav-item:hover {
- background: var(--surface-2);
+ background: light-dark(var(--surface), var(--surface-2));
color: var(--text);
}
.nav-item.active {
- background: light-dark(var(--surface-3), var(--surface-2));
+ background: light-dark(var(--surface), var(--surface-2));
color: var(--text);
+ box-shadow: inset 0 0 0 1px var(--border);
}
.nav-item.active .nav-icon {
color: var(--accent);
@@ -262,6 +271,10 @@
}
.app-shell-narrow .app-shell-main {
grid-row: 2;
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
}
.app-shell-narrow .nav-rail {
position: absolute;
@@ -270,6 +283,8 @@
left: 0;
z-index: 40;
width: min(260px, 86vw);
+ background: var(--surface);
+ border-right: 1px solid var(--border);
box-shadow: var(--shadow);
transform: translateX(-100%);
visibility: hidden;