diff --git a/web/src/App.tsx b/web/src/App.tsx
index 47106ea..96a535f 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -12,6 +12,9 @@ import { useAgentDefinitions } from '@/state/useAgentDefinitions';
import { useSessionFull } from '@/state/useSessionFull';
import { MonitorRail } from '@/monitors/MonitorRail';
import { NewSessionModal } from '@/modals/NewSessionModal';
+import { TabletShell } from '@/shell/TabletShell';
+import { MobileShell } from '@/shell/MobileShell';
+import { useBreakpoint } from '@/state/useBreakpoint';
const UI_VERSION = 'v2.0.0-rc1';
const RUNTIME_VERSION_FALLBACK = 'unknown';
@@ -40,6 +43,7 @@ export function App() {
const approvals = useApprovalsQueue();
const agents = useAgentDefinitions();
const sessionFull = useSessionFull(activeSid);
+ const breakpoint = useBreakpoint();
const brandName = uiHints.data?.brand_name ?? 'ASR';
const envName = uiHints.data?.environments?.[0] ?? 'dev';
@@ -75,23 +79,43 @@ export function App() {
activeAgent={null}
graphVersion={`v${agents.data?.list.length ?? 0}`}
/>
-
-
-
-
-
+ ) : (
+
+
+
+
+
+ )}
void;
+ title: string;
+ children: ReactNode;
+ testId?: string;
+}
+
+const overlay: CSSProperties = {
+ position: 'fixed',
+ inset: 0,
+ background: 'rgba(21,17,10,0.30)',
+ backdropFilter: 'blur(2px)',
+ zIndex: 1000,
+};
+
+const content: CSSProperties = {
+ position: 'fixed',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: 'min(85vh, 720px)',
+ background: 'var(--bg-page)',
+ borderTop: '1px solid var(--hair-strong)',
+ borderRadius: 0,
+ zIndex: 1001,
+ display: 'flex',
+ flexDirection: 'column',
+ boxShadow: 'var(--e-3)',
+ animation: 'asr-sheet-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1)',
+};
+
+const handle: CSSProperties = {
+ width: 44, height: 4,
+ background: 'var(--ink-4)',
+ margin: '8px auto 4px',
+ opacity: 0.35,
+};
+
+const titleRow: CSSProperties = {
+ height: 40, padding: '0 16px',
+ display: 'flex', alignItems: 'center',
+ borderBottom: '1px solid var(--hair)',
+};
+
+export function MobileSheet({ open, onOpenChange, title, children, testId }: MobileSheetProps) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+ ×
+
+ {children}
+
+
+
+ );
+}
diff --git a/web/src/shell/MobileShell.tsx b/web/src/shell/MobileShell.tsx
new file mode 100644
index 0000000..d07a2d1
--- /dev/null
+++ b/web/src/shell/MobileShell.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react';
+import type { CSSProperties } from 'react';
+import { SessionsRail } from '@/shell/SessionsRail';
+import { SessionCanvas } from '@/canvas/SessionCanvas';
+import { MonitorRail } from '@/monitors/MonitorRail';
+import { MobileSheet } from '@/shell/MobileSheet';
+import type { SessionSummary } from '@/state/useSessionList';
+import type { AgentDefinition, ToolCall } from '@/api/types';
+
+interface MobileShellProps {
+ sessions: SessionSummary[];
+ activeSid: string | null;
+ onSelectSession: (sid: string) => void;
+ queue: SessionSummary[];
+ agentsByName: Record;
+ toolCalls: ToolCall[];
+}
+
+const shellWrap: CSSProperties = {
+ display: 'grid',
+ gridTemplateRows: '1fr auto',
+ minHeight: 0,
+};
+
+const tabBar: CSSProperties = {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(3, 1fr)',
+ borderTop: '1px solid var(--hair-strong)',
+ background: 'var(--bg-elev)',
+ height: 56,
+};
+
+const tabBtn = (active: boolean): CSSProperties => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontFamily: 'var(--ff-mono)',
+ fontSize: 11,
+ letterSpacing: '0.12em',
+ textTransform: 'uppercase',
+ color: active ? 'var(--ink-1)' : 'var(--ink-3)',
+ background: 'transparent',
+ border: 'none',
+ borderTop: active ? '2px solid var(--acc)' : '2px solid transparent',
+ cursor: 'pointer',
+ padding: 0,
+});
+
+type ActiveSheet = 'sessions' | 'monitors' | null;
+
+export function MobileShell({
+ sessions, activeSid, onSelectSession, queue, agentsByName, toolCalls,
+}: MobileShellProps) {
+ const [sheet, setSheet] = useState(null);
+
+ function selectAndClose(sid: string) {
+ onSelectSession(sid);
+ setSheet(null);
+ }
+
+ return (
+
+
+
+
+
+
setSheet(o ? 'sessions' : null)}
+ title="Sessions"
+ testId="sessions"
+ >
+
+
+
setSheet(o ? 'monitors' : null)}
+ title="Monitors"
+ testId="monitors"
+ >
+
+
+
+ );
+}
diff --git a/web/src/shell/TabletShell.tsx b/web/src/shell/TabletShell.tsx
new file mode 100644
index 0000000..4fe574e
--- /dev/null
+++ b/web/src/shell/TabletShell.tsx
@@ -0,0 +1,112 @@
+import { useState } from 'react';
+import type { CSSProperties } from 'react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { SessionsRail } from '@/shell/SessionsRail';
+import { SessionCanvas } from '@/canvas/SessionCanvas';
+import { MonitorRail } from '@/monitors/MonitorRail';
+import type { SessionSummary } from '@/state/useSessionList';
+import type { AgentDefinition, ToolCall } from '@/api/types';
+
+interface TabletShellProps {
+ sessions: SessionSummary[];
+ activeSid: string | null;
+ onSelectSession: (sid: string) => void;
+ queue: SessionSummary[];
+ agentsByName: Record;
+ toolCalls: ToolCall[];
+}
+
+const grid: CSSProperties = {
+ display: 'grid',
+ gridTemplateColumns: '180px 1fr',
+ minHeight: 0,
+ position: 'relative',
+};
+
+const monitorsBtn: CSSProperties = {
+ position: 'absolute',
+ top: 12,
+ right: 12,
+ height: 28,
+ padding: '0 12px',
+ fontFamily: 'var(--ff-mono)',
+ fontSize: 11,
+ letterSpacing: '0.06em',
+ textTransform: 'uppercase',
+ color: 'var(--ink-1)',
+ background: 'var(--bg-elev)',
+ border: '1px solid var(--hair-strong)',
+ borderRadius: 0,
+ cursor: 'pointer',
+ zIndex: 5,
+};
+
+const sheetContent: CSSProperties = {
+ position: 'fixed',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: 'min(360px, 90vw)',
+ background: 'var(--bg-page)',
+ borderLeft: '1px solid var(--hair-strong)',
+ boxShadow: 'var(--e-3)',
+ overflow: 'auto',
+ zIndex: 1000,
+ display: 'flex',
+ flexDirection: 'column',
+};
+
+export function TabletShell({
+ sessions, activeSid, onSelectSession, queue, agentsByName, toolCalls,
+}: TabletShellProps) {
+ const [monitorsOpen, setMonitorsOpen] = useState(false);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Monitors
+
+ { onSelectSession(sid); setMonitorsOpen(false); }}
+ />
+
+
+
+
+ );
+}
diff --git a/web/src/state/useBreakpoint.ts b/web/src/state/useBreakpoint.ts
new file mode 100644
index 0000000..0839b79
--- /dev/null
+++ b/web/src/state/useBreakpoint.ts
@@ -0,0 +1,35 @@
+import { useEffect, useState } from 'react';
+
+export type Breakpoint = 'mobile' | 'tablet' | 'desktop';
+
+const TABLET_MIN = '(min-width: 768px)';
+const DESKTOP_MIN = '(min-width: 1200px)';
+
+function readNow(): Breakpoint {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return 'desktop';
+ }
+ if (window.matchMedia(DESKTOP_MIN).matches) return 'desktop';
+ if (window.matchMedia(TABLET_MIN).matches) return 'tablet';
+ return 'mobile';
+}
+
+export function useBreakpoint(): Breakpoint {
+ const [bp, setBp] = useState(readNow);
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
+ const tabletMq = window.matchMedia(TABLET_MIN);
+ const desktopMq = window.matchMedia(DESKTOP_MIN);
+ const update = () => setBp(readNow());
+ tabletMq.addEventListener('change', update);
+ desktopMq.addEventListener('change', update);
+ update();
+ return () => {
+ tabletMq.removeEventListener('change', update);
+ desktopMq.removeEventListener('change', update);
+ };
+ }, []);
+
+ return bp;
+}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index 7a7e5d4..2c55b1e 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -104,3 +104,8 @@ body {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
+
+@keyframes asr-sheet-slide-up {
+ from { opacity: 0; transform: translateY(8%); }
+ to { opacity: 1; transform: translateY(0); }
+}
diff --git a/web/tests/component/MobileSheet.test.tsx b/web/tests/component/MobileSheet.test.tsx
new file mode 100644
index 0000000..47a6fb7
--- /dev/null
+++ b/web/tests/component/MobileSheet.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '../_helpers/render';
+import { MobileSheet } from '@/shell/MobileSheet';
+
+describe('', () => {
+ it('does not render content when closed', () => {
+ render(
+ {}} title="Sessions">
+ body content
+ ,
+ );
+ expect(screen.queryByText('body content')).not.toBeInTheDocument();
+ });
+
+ it('renders title + body + handle when open', () => {
+ render(
+ {}} title="Sessions" testId="sessions">
+ body content
+ ,
+ );
+ expect(screen.getByText('Sessions')).toBeInTheDocument();
+ expect(screen.getByText('body content')).toBeInTheDocument();
+ expect(document.querySelector('[data-mobile-sheet="sessions"]')).toBeInTheDocument();
+ });
+
+ it('calls onOpenChange(false) when Close is tapped', () => {
+ const onOpenChange = vi.fn();
+ render(
+
+ body
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /close/i }));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/web/tests/component/MobileShell.test.tsx b/web/tests/component/MobileShell.test.tsx
new file mode 100644
index 0000000..d4a6a86
--- /dev/null
+++ b/web/tests/component/MobileShell.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '../_helpers/render';
+import { MobileShell } from '@/shell/MobileShell';
+
+const ORIGINAL_FETCH = globalThis.fetch;
+
+beforeEach(() => {
+ globalThis.fetch = vi.fn(() => Promise.resolve({
+ ok: true, status: 200, statusText: 'OK',
+ clone() { return this; },
+ json: () => Promise.resolve({}),
+ } as unknown as Response));
+});
+afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; });
+
+describe('', () => {
+ it('renders 3-tab bottom nav and canvas by default', () => {
+ render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(screen.getByRole('navigation', { name: /mobile/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /open sessions/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /show canvas/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /open monitors/i })).toBeInTheDocument();
+ expect(screen.getByText(/Select a session/i)).toBeInTheDocument();
+ });
+
+ it('opens sessions sheet on tap', () => {
+ render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(document.querySelector('[data-mobile-sheet="sessions"]')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /open sessions/i }));
+ expect(document.querySelector('[data-mobile-sheet="sessions"]')).toBeInTheDocument();
+ });
+
+ it('opens monitors sheet on tap and closes via the sheet Close button', () => {
+ render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /open monitors/i }));
+ expect(document.querySelector('[data-mobile-sheet="monitors"]')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /close/i }));
+ expect(document.querySelector('[data-mobile-sheet="monitors"]')).not.toBeInTheDocument();
+ });
+
+ it('stamps data-shell="mobile" for layout snapshots', () => {
+ const { container } = render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(container.querySelector('[data-shell="mobile"]')).toBeInTheDocument();
+ });
+});
diff --git a/web/tests/component/TabletShell.test.tsx b/web/tests/component/TabletShell.test.tsx
new file mode 100644
index 0000000..ffefbe3
--- /dev/null
+++ b/web/tests/component/TabletShell.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '../_helpers/render';
+import { TabletShell } from '@/shell/TabletShell';
+
+const ORIGINAL_FETCH = globalThis.fetch;
+
+beforeEach(() => {
+ globalThis.fetch = vi.fn(() => Promise.resolve({
+ ok: true, status: 200, statusText: 'OK',
+ clone() { return this; },
+ json: () => Promise.resolve({}),
+ } as unknown as Response));
+});
+afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; });
+
+describe('', () => {
+ it('renders SessionsRail + Monitors button + empty SessionCanvas hint', () => {
+ render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(screen.getAllByText(/Sessions/i).length).toBeGreaterThan(0);
+ expect(screen.getByRole('button', { name: /open monitors/i })).toBeInTheDocument();
+ expect(screen.getByText(/Select a session/i)).toBeInTheDocument();
+ });
+
+ it('uses a 180px sessions rail grid template (tablet shell marker)', () => {
+ const { container } = render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(container.querySelector('[data-shell="tablet"]')).toBeInTheDocument();
+ });
+
+ it('opens the monitors sheet when the button is clicked', () => {
+ render(
+ {}}
+ queue={[]} agentsByName={{}} toolCalls={[]}
+ />,
+ );
+ expect(document.querySelector('[data-monitors-sheet]')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /open monitors/i }));
+ expect(document.querySelector('[data-monitors-sheet]')).toBeInTheDocument();
+ });
+});
diff --git a/web/tests/component/useBreakpoint.test.tsx b/web/tests/component/useBreakpoint.test.tsx
new file mode 100644
index 0000000..3d0f3a6
--- /dev/null
+++ b/web/tests/component/useBreakpoint.test.tsx
@@ -0,0 +1,89 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useBreakpoint } from '@/state/useBreakpoint';
+
+type Listener = (e: { matches: boolean }) => void;
+
+interface MockMediaQueryList {
+ matches: boolean;
+ media: string;
+ addEventListener: (event: 'change', listener: Listener) => void;
+ removeEventListener: (event: 'change', listener: Listener) => void;
+ fire: (matches: boolean) => void;
+}
+
+const originalMatchMedia = window.matchMedia;
+
+function installMatchMedia(width: number) {
+ const lists: MockMediaQueryList[] = [];
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ configurable: true,
+ value: (query: string) => {
+ const min = /\(min-width:\s*(\d+)px\)/.exec(query);
+ const threshold = min ? Number(min[1]) : 0;
+ const matches = width >= threshold;
+ const listeners = new Set();
+ const list: MockMediaQueryList = {
+ matches,
+ media: query,
+ addEventListener: (_e, l) => { listeners.add(l); },
+ removeEventListener: (_e, l) => { listeners.delete(l); },
+ fire: (m) => { list.matches = m; listeners.forEach((l) => l({ matches: m })); },
+ };
+ lists.push(list);
+ return list as unknown as MediaQueryList;
+ },
+ });
+ return {
+ setWidth(newWidth: number) {
+ width = newWidth;
+ for (const list of lists) {
+ const min = /\(min-width:\s*(\d+)px\)/.exec(list.media);
+ const threshold = min ? Number(min[1]) : 0;
+ list.fire(width >= threshold);
+ }
+ },
+ };
+}
+
+describe('useBreakpoint', () => {
+ beforeEach(() => { /* installed per-test */ });
+ afterEach(() => {
+ Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: originalMatchMedia });
+ });
+
+ it('returns "mobile" when width < 768', () => {
+ installMatchMedia(500);
+ const { result } = renderHook(() => useBreakpoint());
+ expect(result.current).toBe('mobile');
+ });
+
+ it('returns "tablet" when 768 <= width < 1200', () => {
+ installMatchMedia(900);
+ const { result } = renderHook(() => useBreakpoint());
+ expect(result.current).toBe('tablet');
+ });
+
+ it('returns "desktop" when width >= 1200', () => {
+ installMatchMedia(1440);
+ const { result } = renderHook(() => useBreakpoint());
+ expect(result.current).toBe('desktop');
+ });
+
+ it('updates when matchMedia change events fire', () => {
+ const mm = installMatchMedia(500);
+ const { result } = renderHook(() => useBreakpoint());
+ expect(result.current).toBe('mobile');
+ act(() => { mm.setWidth(1000); });
+ expect(result.current).toBe('tablet');
+ act(() => { mm.setWidth(1500); });
+ expect(result.current).toBe('desktop');
+ });
+
+ it('falls back to "desktop" when matchMedia is unavailable (SSR-safe)', () => {
+ Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined });
+ const { result } = renderHook(() => useBreakpoint());
+ expect(result.current).toBe('desktop');
+ });
+});