diff --git a/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png b/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png
new file mode 100644
index 0000000..fee5db7
Binary files /dev/null and b/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3039af..5c14614 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,74 @@
## [5.2.0] - Unreleased
-Nothing yet — this section collects the work merged onto the 5.2.0 branch.
+### Changed
+- **A phone gets a layout built around the conversation.** It used to be the
+ desktop layout at the same size: the figures you read mid-session — the cost,
+ the model, the state, whether approvals are bypassed — were set smaller than
+ the body text, the controls sat close enough together that hitting the
+ intended one was luck, and more than half the screen went to chrome.
+
+ Now the chat surface is one slim strip above the conversation and one row
+ below it. The strip says what the session is doing and what it has cost;
+ tapping it opens the runtime, the folder, the branch, the tokens, the context
+ meter and the approvals state. The row below is the message field and send,
+ with the attachments, the pickers, the model and the approvals readout behind
+ a *More*.
+
+ The bottom bar is now a set of destinations rather than a drawer of commands.
+ What this app does is run agent sessions, and inside one there are four places
+ worth being — the conversation, what the agent did about it, the files it did
+ that to, and a shell in the same directory — plus the other sessions. Those
+ are the five, it says which one you are on, and pressing one goes there. What
+ used to sit in those slots mixed a place to go, a thing to make, a panel to
+ toggle, a file to attach and a sheet of everything left over.
+
+ The verbs moved to a square button floating in the bottom right: search this
+ conversation, jump to a turn, display settings, new session, attach an image,
+ rename, reconnect, and the rest behind *More*. A control that changes where
+ you are and a control that does something to where you are no longer share a
+ row.
+
+ The conversation now has about seven tenths of a phone screen, where it had
+ under half, and the bar gets out of the way entirely while the on-screen
+ keyboard is up. Everything a finger is meant to hit is at least 44px with real
+ space around it, nothing carrying live information is smaller than the body
+ text, and every control says what it is rather than being a bare glyph with a
+ tooltip no touch screen can show. The same treatment reaches the trace rail,
+ the turn index, the model list, the more sheet, the tab switcher and the
+ dialogs. The desktop and tablet layouts are unchanged.
+
+### Fixed
+- **A phone no longer opens a conversation onto a panel.** Which panel is open
+ is a desktop preference — there the rail sits beside the transcript — but on a
+ phone it replaces it, so the stored setting put every conversation behind a
+ panel. It is session state on a phone now, and the shared preference is left
+ alone rather than overwritten, which would have closed the rail on the desktop
+ that set it open.
+- **The conversation no longer spills over the live ribbon and the composer on
+ a short screen.** The transcript kept a fixed minimum height it could not
+ give up, so on a phone in landscape — or any window short enough — it grew
+ past the space it had and was painted over the two things below it.
+
+### Internal
+- The automated browser checks run at phone viewports (portrait, keyboard-open
+ and landscape), with each of the phone's own disclosures and its menu open in
+ turn, and assert the geometry rather than the intent: target size, the space
+ between neighbours, type size, that the named live figures are legible and
+ reachable, that every control can be identified without pressing it, that no
+ region is drawn over another, and that the chrome takes no more than 170px
+ from the conversation — nothing else would have noticed that drifting back,
+ since every other rule is about the chrome being big enough.
+
+ Three defects in the checks themselves came out of it. They ran in an 800x600
+ window while mounting a 390x740 fixture, so a third of it was off-viewport
+ and anything that asks the viewport a question got the wrong answer. They
+ loaded none of the app's own stylesheets, without which every
+ `var(--text-2xs)` resolved to nothing and a type-size check read 16px for
+ text that ships at 10. And a panel animating in from `opacity: 0` was skipped
+ as invisible in headless Chrome, so a whole state was reported clean without
+ measuring anything — the menu rises without fading now, which also means it
+ is there under reduced motion and on a dropped first frame.
## [5.1.2] - 2026-07-26
diff --git a/src/client/shell/AppShell.tsx b/src/client/shell/AppShell.tsx
index 599afc4..cc1bbd7 100644
--- a/src/client/shell/AppShell.tsx
+++ b/src/client/shell/AppShell.tsx
@@ -4,6 +4,7 @@ import type { AppSettings } from '../types';
import { Badge } from '../ui/relay/Badge';
import { CommandPalette, type CommandPaletteGroup } from '../ui/relay/CommandPalette';
import { Icon } from '../ui/relay/Icon';
+import { PhoneContext } from '../ui/touch';
import { IconButton } from '../ui/relay/IconButton';
import { StatusBar, type StatusBarSegment } from '../ui/relay/StatusBar';
import { TabBar, type TabItem } from '../ui/relay/TabBar';
@@ -18,7 +19,8 @@ import { SessionsDialog } from './dialogs/SessionsDialog';
import { ChatSettingsDialog } from './dialogs/ChatSettingsDialog';
import { SettingsDialog } from './dialogs/SettingsDialog';
import { TerminalOptionsDialog } from './dialogs/TerminalOptionsDialog';
-import { MobileBar, type MobileBarAction } from './MobileBar';
+import { BottomNav, type BottomNavDestination } from './BottomNav';
+import { FloatingMenu, type FloatingMenuAction } from './FloatingMenu';
import { TabSwitcherSheet } from './TabSwitcherSheet';
import { KeyStrip, KEY_STRIP_HEIGHT } from './KeyStrip';
import type { MobileKey } from '../ui/mobile';
@@ -33,7 +35,7 @@ import { TabContextMenu } from './TabContextMenu';
import { TerminalHost } from './TerminalHost';
import { ChatView } from './chat/ChatView';
import type { ChatController } from '../chat/controller';
-import type { ChatViewSettings } from '../chat/view-settings';
+import { CHAT_PANEL_ICONS, type ChatPanelId, type ChatViewSettings } from '../chat/view-settings';
import { Toasts } from './Toasts';
import { UpdateBannerView } from './UpdateBannerView';
import { shellStore, type ShellState, type ShellTab } from './store';
@@ -147,6 +149,7 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re
// controller is carried as an opaque handle because its transcript mutates
// per token and must not live in shell state.
const chatActive = state.chat.active;
+ const keyboardUp = useKeyboardUp(state.isMobile);
const chatController = state.chat.controller as ChatController | null;
const [menu, setMenu] = React.useState<{ id: string; x: number; y: number } | null>(null);
@@ -309,47 +312,119 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re
{ children: state.theme === 'dark' ? 'Dark' : 'Light' },
];
- const mobileActions: MobileBarAction[] = [
- {
- id: 'sessions',
- label: 'Sessions',
- icon: 'layout-list',
- badge: state.tabs.some((t) => t.unread && t.id !== state.activeId),
- active: state.dialogs.tabs,
- expands: true,
- onPress: () => closeDialogs({ tabs: true }),
+ /**
+ * Where a phone can be, and where it is.
+ *
+ * Inside a conversation the app has four places worth being — the
+ * conversation, what the agent did, the files it did it to, and a shell in
+ * the same directory — plus the other sessions. Outside one there is the
+ * terminal and its keys. Every one of these *replaces* what fills the screen,
+ * which is what makes them destinations rather than toggles.
+ */
+ /**
+ * On a phone, which panel is open is where you are — not a preference.
+ *
+ * The rail replaces the conversation there rather than sitting beside it, so
+ * a stored `panelOpen: true` would open every conversation onto a panel with
+ * the conversation behind it. And writing the phone's answer back would
+ * clobber the desktop's: the setting is shared, so one session on a phone
+ * would silently close the rail on the machine it was set open on.
+ *
+ * So on a phone it lives here, for the life of the tab, and the persisted
+ * settings never hear about it.
+ */
+ const [phonePanel, setPhonePanel] = React.useState<{ open: boolean; tab: ChatPanelId }>(() => ({
+ open: false,
+ tab: state.chatView.panelTab,
+ }));
+ const view: ChatViewSettings = state.isMobile
+ ? { ...state.chatView, panelOpen: phonePanel.open, panelTab: phonePanel.tab }
+ : state.chatView;
+ const setView = React.useCallback(
+ (next: ChatViewSettings): void => {
+ if (!shellStore.getSnapshot().isMobile) {
+ actions.setChatView(next);
+ return;
+ }
+ setPhonePanel({ open: next.panelOpen, tab: next.panelTab });
+ // Everything else is still a preference and still persists; only the two
+ // fields that mean "where am I" are held back.
+ const stored = shellStore.getSnapshot().chatView;
+ actions.setChatView({ ...next, panelOpen: stored.panelOpen, panelTab: stored.panelTab });
},
- { id: 'new', label: 'New', icon: 'plus', onPress: actions.newTab },
- // One slot, two jobs, decided by what is on screen.
- //
- // The key strip sends terminal control codes, which a conversation has no
- // use for; the workspace panel is meaningless without one. Giving each its
- // own slot would put six items on a 390px bar with one of them always
- // inert. This shows whichever the current surface can actually use.
- chatActive
- ? {
- id: 'workspace',
- label: 'Panel',
- icon: 'panel-left',
- active: state.chatView.panelOpen,
- toggle: true,
- onPress: () =>
- actions.setChatView({ ...state.chatView, panelOpen: !state.chatView.panelOpen }),
- }
- : {
- // The on-screen key strip carries Escape now; this toggles it for the
- // moments the terminal needs the vertical room back.
+ [actions],
+ );
+
+ const goChat = (): void => setView({ ...view, panelOpen: false, terminalOpen: false });
+ const goPanel = (panelTab: ChatPanelId): void =>
+ setView({ ...view, panelOpen: true, panelTab, terminalOpen: false });
+
+ const destinations: BottomNavDestination[] = chatActive
+ ? [
+ {
+ id: 'chat',
+ label: 'Chat',
+ icon: 'message-square',
+ current: !view.panelOpen && !view.terminalOpen,
+ onGo: goChat,
+ },
+ {
+ id: 'trace',
+ label: 'Trace',
+ icon: CHAT_PANEL_ICONS.trace,
+ current: view.panelOpen && view.panelTab === 'trace',
+ onGo: () => goPanel('trace'),
+ },
+ {
+ id: 'files',
+ label: 'Files',
+ icon: CHAT_PANEL_ICONS.files,
+ current: view.panelOpen && view.panelTab !== 'trace',
+ onGo: () => goPanel('files'),
+ },
+ {
+ id: 'terminal',
+ label: 'Shell',
+ icon: 'terminal',
+ current: view.terminalOpen,
+ onGo: () => setView({ ...view, panelOpen: false, terminalOpen: true }),
+ },
+ {
+ id: 'sessions',
+ label: 'Sessions',
+ icon: 'layout-list',
+ badge: state.tabs.some((t) => t.unread && t.id !== state.activeId),
+ onGo: () => closeDialogs({ tabs: true }),
+ },
+ ]
+ : [
+ { id: 'terminal', label: 'Shell', icon: 'terminal', current: true, onGo: () => {} },
+ {
id: 'keys',
label: 'Keys',
icon: 'keyboard',
- active: state.keysVisible,
- toggle: true,
- onPress: actions.toggleKeys,
+ current: state.keysVisible,
+ onGo: actions.toggleKeys,
},
- { id: 'image', label: 'Image', icon: 'image', onPress: actions.attachImage },
+ {
+ id: 'sessions',
+ label: 'Sessions',
+ icon: 'layout-list',
+ badge: state.tabs.some((t) => t.unread && t.id !== state.activeId),
+ onGo: () => closeDialogs({ tabs: true }),
+ },
+ ];
+
+ // Verbs, not places — see BottomNav. These are what the floating button is
+ // for, and what a destination bar has no business holding.
+ const sessionActions: FloatingMenuAction[] = [
+ { id: 'new', label: 'New session', icon: 'plus', onPress: actions.newTab },
+ { id: 'image', label: 'Attach an image', icon: 'image', onPress: actions.attachImage },
+ { id: 'rename', label: 'Rename this session', icon: 'pencil', disabled: !active, onPress: () => active && closeDialogs({ rename: active.id }) },
+ { id: 'reconnect', label: 'Reconnect', icon: 'rotate-cw', onPress: actions.reconnect },
{
id: 'more',
- label: 'More',
+ label: 'More…',
icon: 'ellipsis',
active: state.dialogs.more,
expands: true,
@@ -357,6 +432,8 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re
},
];
+
+
// No brand on a phone. It cost about a third of a 390px tab strip to say
// something the user already knows, and the working directory that stood
// there instead only repeated the tab title. The tabs get the room.
@@ -414,6 +491,12 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re
);
return (
+ // The shell's phone answer, published to everything under it — including
+ // the dialogs, which render here rather than inside any conversation and
+ // would otherwise be the one part of a phone still drawn at desktop sizes.
+ // ChatView publishes its own for the same value; a conversation rendered
+ // outside this shell still has to know. See ui/touch.ts.
+
closeDialogs({ chatSettings: true })}
+ menuActions={sessionActions}
// The chat surface owns the whole viewport, so the tab strip's own
// theme button is off-screen while a conversation is showing.
theme={state.theme}
@@ -528,7 +614,25 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re
/>
) : null}
- {state.isMobile ? : }
+ {/* The phone's only permanent chrome. A bar along the bottom edge cost
+ 56px plus the safe-area inset of a screen whose whole point is the
+ conversation; this costs the corner it covers. */}
+ {/* Over a conversation the menu belongs to the chat surface, which is
+ the only thing that knows where its composer ends — see ChatView. Out
+ here it covers the terminal, which has no bottom-right control of its
+ own to collide with. */}
+ {state.isMobile && !chatActive ? (
+
+ ) : null}
+
+ {state.isMobile ? (
+
+ ) : (
+
+ )}
tab.id === state.activeId && tab.surface !== 'chat')}
/>
+
);
}
@@ -764,3 +869,37 @@ async function pickAndUpload(sessionId: string, directory: string): Promise {
+ const viewport = typeof window === 'undefined' ? null : window.visualViewport;
+ if (!isMobile || !viewport) {
+ setUp(false);
+ return;
+ }
+ const measure = (): void => setUp(window.innerHeight - viewport.height > 160);
+ measure();
+ viewport.addEventListener('resize', measure);
+ return () => viewport.removeEventListener('resize', measure);
+ }, [isMobile]);
+
+ return up;
+}
diff --git a/src/client/shell/BottomNav.tsx b/src/client/shell/BottomNav.tsx
new file mode 100644
index 0000000..afb91de
--- /dev/null
+++ b/src/client/shell/BottomNav.tsx
@@ -0,0 +1,142 @@
+import * as React from 'react';
+
+import { Icon } from '../ui/relay/Icon';
+import { PHONE_TEXT, TOUCH_TARGET } from '../ui/touch';
+
+/**
+ * The phone's bottom bar: where you are in the app, and how to be somewhere
+ * else.
+ *
+ * Destinations, not commands. The bar this replaced held whatever five things
+ * seemed useful — Sessions, New, Panel, Image, More — which mixed a place to
+ * go, a thing to make, a panel to toggle, a file to attach and a sheet of
+ * everything left over. Five slots of the most valuable chrome in the app,
+ * spent on a list with no idea in it.
+ *
+ * What the app actually does is run agent sessions, and inside one there are
+ * four places worth being: the conversation, what the agent did about it, the
+ * files it did that to, and a shell in the same directory. Plus the other
+ * sessions. Those are the destinations, and the bar is where you switch
+ * between them — so it paints which one you are on, the way a bar that says
+ * "you are here" is supposed to.
+ *
+ * Everything that is a *verb* — search, new session, rename, reconnect, the
+ * display settings — is on the floating button instead. A control that changes
+ * where you are and a control that does something to where you are do not
+ * belong in the same row.
+ *
+ * Height is published as `--mobile-bar-height` so the toast stack and the
+ * terminal padding can both clear it without either duplicating the number.
+ */
+
+export interface BottomNavDestination {
+ id: string;
+ label: string;
+ icon: string;
+ /** You are here. Exactly one of these should be true. */
+ current?: boolean;
+ onGo(): void;
+ /** Draws attention without a count, e.g. output arrived somewhere else. */
+ badge?: boolean;
+ disabled?: boolean;
+}
+
+export interface BottomNavProps {
+ destinations: BottomNavDestination[];
+ /**
+ * Hidden while the on-screen keyboard is up.
+ *
+ * The keyboard takes half the screen, and the half it leaves is the half you
+ * are typing into — which is the one moment nobody is navigating. The bar
+ * comes straight back when the field is done with.
+ */
+ hidden?: boolean;
+}
+
+export function BottomNav({ destinations, hidden = false }: BottomNavProps): React.JSX.Element | null {
+ if (hidden || destinations.length === 0) return null;
+
+ return (
+
+ );
+}
diff --git a/src/client/shell/FloatingMenu.tsx b/src/client/shell/FloatingMenu.tsx
new file mode 100644
index 0000000..c9a5a11
--- /dev/null
+++ b/src/client/shell/FloatingMenu.tsx
@@ -0,0 +1,275 @@
+import * as React from 'react';
+
+import { Icon } from '../ui/relay/Icon';
+import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET } from '../ui/touch';
+
+/**
+ * The phone's one piece of permanent chrome: a square button in the bottom
+ * right, and everything else behind it.
+ *
+ * It replaced the bottom bar. A five-slot bar is 56px plus the safe-area inset
+ * of screen that the conversation never gets back, on a surface whose whole
+ * purpose is the conversation — and it could only ever show five things, so
+ * every other control had to live in a sheet reached *through* it anyway. A
+ * button that is 56px square and floats over the transcript costs the corner it
+ * covers and nothing else.
+ *
+ * Open, it is a column of labelled rows rising from the button. Rows rather
+ * than a grid because the labels are what make the icons legible, and a phone
+ * has height to give a list and no width to give a grid.
+ */
+
+export interface FloatingMenuAction {
+ id: string;
+ label: string;
+ icon: string;
+ onPress(): void;
+ /** Paints the row as the current state of something, e.g. an open panel. */
+ active?: boolean;
+ /** Opens a panel or sheet rather than navigating. */
+ expands?: boolean;
+ /** Toggles something visible. Announced as pressed/not-pressed. */
+ toggle?: boolean;
+ /** Draws attention without a count, e.g. background output arrived. */
+ badge?: boolean;
+ disabled?: boolean;
+ /** Rows are drawn grouped by this, newest group nearest the button. */
+ group?: string;
+}
+
+export interface FloatingMenuProps {
+ actions: FloatingMenuAction[];
+ /** Sits above whatever owns the bottom edge, e.g. the on-screen key strip. */
+ bottomOffset?: number;
+}
+
+const SIDE = 56;
+
+export function FloatingMenu({ actions, bottomOffset = 0 }: FloatingMenuProps): React.JSX.Element {
+ const [open, setOpen] = React.useState(false);
+ const alerting = actions.some((action) => action.badge);
+
+ // Escape closes it, and so does the surface underneath being replaced: a menu
+ // still standing over a conversation that has been switched away from is a
+ // list of controls for something that is no longer on screen.
+ React.useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape') return;
+ event.stopPropagation();
+ setOpen(false);
+ };
+ window.addEventListener('keydown', onKey, true);
+ return () => window.removeEventListener('keydown', onKey, true);
+ }, [open]);
+
+ const groups: Array<[string, FloatingMenuAction[]]> = [];
+ for (const action of actions) {
+ const key = action.group ?? '';
+ const last = groups[groups.length - 1];
+ if (last && last[0] === key) last[1].push(action);
+ else groups.push([key, [action]]);
+ }
+
+ return (
+ <>
+ {/* Tapping anywhere else closes it. Not a `Dialog`: this must not take
+ focus away from the composer, because the commonest reason to open it
+ is to reach for something mid-sentence. */}
+ {open ? (
+
-
-
+
+
);
@@ -171,10 +179,24 @@ export function TabSwitcherSheet({
onClose={onClose}
footer={
- } onClick={onNew}>
+ {/* `lg` is 38px, which is still short of a thumb, so the height is
+ stated outright — this sheet only ever appears on a phone. */}
+ }
+ onClick={onNew}
+ >
New session
- } onClick={onAllSessions}>
+ }
+ onClick={onAllSessions}
+ >
All sessions
diff --git a/src/client/shell/chat/ActivityTimeline.tsx b/src/client/shell/chat/ActivityTimeline.tsx
index 5f83660..db36eb3 100644
--- a/src/client/shell/chat/ActivityTimeline.tsx
+++ b/src/client/shell/chat/ActivityTimeline.tsx
@@ -9,6 +9,7 @@ import {
import type { ActivityFilterId } from '../../chat/view-settings.js';
import { KIND_ICON, TOOL_STATUS } from '../../chat/tool-meta.js';
import { Icon } from '../../ui/relay/Icon.js';
+import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js';
import { Markdown } from './Markdown.js';
import { ToolCallCard } from './ToolCallCard.js';
@@ -59,6 +60,7 @@ export function ActivityTimeline({
const [expanded, setExpanded] = React.useState>(() => new Set());
const scroller = React.useRef(null);
const rows = React.useRef(new Map());
+ const isPhone = usePhone();
const filtered = React.useMemo(() => filterActivity(events, filter), [events, filter]);
@@ -122,9 +124,11 @@ export function ActivityTimeline({
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
- gap: 4,
- height: 28,
- padding: '0 12px',
+ flexWrap: isPhone ? 'wrap' : 'nowrap',
+ gap: isPhone ? TOUCH_GAP : 4,
+ height: isPhone ? undefined : 28,
+ minHeight: isPhone ? TOUCH_TARGET + 8 : undefined,
+ padding: isPhone ? '4px 12px' : '0 12px',
borderBottom: '1px solid var(--border)',
}}
>
@@ -141,7 +145,7 @@ export function ActivityTimeline({
marginLeft: 'auto',
flex: '0 0 auto',
fontFamily: 'var(--font-mono)',
- fontSize: 10,
+ fontSize: isPhone ? PHONE_TEXT.meta : 10,
color: 'var(--muted-foreground)',
whiteSpace: 'nowrap',
}}
@@ -197,12 +201,13 @@ export function ActivityTimeline({
style={{
alignSelf: 'flex-start',
marginBottom: 4,
- padding: '2px 6px',
+ minHeight: isPhone ? TOUCH_TARGET : undefined,
+ padding: isPhone ? '0 12px' : '2px 6px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
fontFamily: 'var(--font-mono)',
- fontSize: 10,
+ fontSize: isPhone ? PHONE_TEXT.body : 10,
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
@@ -246,6 +251,7 @@ function FilterChip({
onClick: () => void;
}): React.JSX.Element {
const [hover, setHover] = React.useState(false);
+ const isPhone = usePhone();
return (
void;
}): React.JSX.Element {
const [hover, setHover] = React.useState(false);
+ const isPhone = usePhone();
const bodyId = React.useId();
const reasoning = event.kind === 'reasoning';
const status = TOOL_STATUS[event.status] || TOOL_STATUS.completed;
@@ -359,8 +366,8 @@ const ActivityRow = React.memo(function ActivityRow({
gap: 8,
width: '100%',
minWidth: 0,
- minHeight: 24,
- padding: 0,
+ minHeight: isPhone ? TOUCH_TARGET : 24,
+ padding: isPhone ? '4px 0' : 0,
textAlign: 'left',
background: hover ? 'var(--accent)' : 'transparent',
border: 0,
@@ -372,7 +379,7 @@ const ActivityRow = React.memo(function ActivityRow({
{reasoning ? null : (
)}
@@ -380,7 +387,7 @@ const ActivityRow = React.memo(function ActivityRow({
style={{
flex: '0 0 auto',
fontFamily: 'var(--font-mono)',
- fontSize: 10.5,
+ fontSize: isPhone ? PHONE_TEXT.meta : 10.5,
color: reasoning
? 'var(--muted-foreground)'
: failed
@@ -395,7 +402,7 @@ const ActivityRow = React.memo(function ActivityRow({
flex: 1,
minWidth: 0,
fontFamily: 'var(--font-mono)',
- fontSize: 10.5,
+ fontSize: isPhone ? PHONE_TEXT.meta : 10.5,
color: 'var(--muted-foreground)',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -409,7 +416,7 @@ const ActivityRow = React.memo(function ActivityRow({
style={{
flex: '0 0 auto',
fontFamily: 'var(--font-mono)',
- fontSize: 10,
+ fontSize: isPhone ? PHONE_TEXT.meta : 10,
color: running ? 'var(--info)' : 'var(--muted-foreground)',
}}
>
@@ -463,18 +470,18 @@ const ActivityRow = React.memo(function ActivityRow({
display: 'inline-flex',
alignItems: 'center',
gap: 5,
- height: 20,
- padding: '0 6px',
+ height: isPhone ? TOUCH_TARGET : 20,
+ padding: isPhone ? '0 12px' : '0 6px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
fontFamily: 'var(--font-mono)',
- fontSize: 10,
+ fontSize: isPhone ? PHONE_TEXT.body : 10,
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
>
-
+
show in transcript
) : null}
diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx
index 77f4afd..f9b0e8f 100644
--- a/src/client/shell/chat/ChatView.tsx
+++ b/src/client/shell/chat/ChatView.tsx
@@ -16,6 +16,8 @@ import { Button } from '../../ui/relay/Button.js';
import { Icon } from '../../ui/relay/Icon.js';
import { IconButton } from '../../ui/relay/IconButton.js';
import { showNotification } from '../../ui/notifications.js';
+import { PhoneContext } from '../../ui/touch.js';
+import { FloatingMenu } from '../FloatingMenu.js';
import { Composer } from './Composer.js';
import { MessageList, type MessageListHandle } from './MessageList.js';
import { messageText } from './MessageBubble.js';
@@ -72,6 +74,29 @@ export interface ChatViewProps {
onViewChange?: (next: ChatViewSettings) => void;
theme?: 'dark' | 'light';
onToggleTheme?: () => void;
+ /**
+ * The shell's own menu entries — sessions, new, more.
+ *
+ * Passed down rather than this surface's being passed up, because the menu
+ * has to be positioned by whoever knows where the composer is. Anchored to
+ * the shell it sat exactly on top of the send button; anchored to the
+ * transcript it floats over the conversation and clears everything.
+ */
+ menuActions?: SurfaceAction[];
+}
+
+/** One control on the phone's floating menu. */
+export interface SurfaceAction {
+ id: string;
+ label: string;
+ icon: string;
+ onPress(): void;
+ active?: boolean;
+ expands?: boolean;
+ toggle?: boolean;
+ badge?: boolean;
+ disabled?: boolean;
+ group?: string;
}
/**
@@ -102,6 +127,7 @@ export function ChatView({
onViewChange,
theme,
onToggleTheme,
+ menuActions,
}: ChatViewProps) {
const transcript = controller.transcript;
@@ -204,11 +230,6 @@ export function ChatView({
// leave the timeline's effect with nothing to react to.
const [focus, setFocus] = React.useState<{ id?: string; nonce: number }>({ nonce: 0 });
const [indexSheet, setIndexSheet] = React.useState(false);
- // The rail's persisted flag is a desktop preference. On a phone the rail is
- // the whole screen, so opening it is a gesture that happens *now* — a stored
- // `true` would mean every conversation opened onto a panel with the
- // conversation hidden behind it.
- const [mobileRail, setMobileRail] = React.useState(false);
const [planSheet, setPlanSheet] = React.useState(false);
// A draft seed, not a controlled value: making the composer controlled would
// re-render this component — and re-derive the turns and the whole activity
@@ -234,22 +255,24 @@ export function ChatView({
// ------------------------------------------------------------------ zones
const panelsAvailable = enabledPanels(view).length > 0;
- const railOpen = panelsAvailable && (isMobile ? mobileRail : view.panelOpen);
+ // One answer for both, so the phone's bottom bar can drive it. It used to be
+ // separate local state on a phone, which meant the rail was open according to
+ // this component and shut according to everything outside it — including the
+ // bar that now has to paint which destination you are on.
+ const railOpen = panelsAvailable && view.panelOpen;
// On a phone the rail replaces the conversation rather than sitting beside
// it: 344px of panel next to a 390px viewport leaves neither usable.
const railTakesOver = isMobile && railOpen;
const toggleRail = React.useCallback(() => {
- if (isMobile) setMobileRail((open) => !open);
- else setView({ panelOpen: !view.panelOpen });
- }, [isMobile, setView, view.panelOpen]);
+ setView({ panelOpen: !view.panelOpen });
+ }, [setView, view.panelOpen]);
const openRail = React.useCallback(
(panelTab: ChatPanelId) => {
- if (isMobile) setMobileRail(true);
if (!view.panelOpen || view.panelTab !== panelTab) setView({ panelOpen: true, panelTab });
},
- [isMobile, setView, view.panelOpen, view.panelTab],
+ [setView, view.panelOpen, view.panelTab],
);
const narrow = width > 0 && width < HIDE_INDEX_AT;
@@ -493,7 +516,36 @@ export function ChatView({
/>
);
+ const openIndex = React.useCallback(() => {
+ // One control, two jobs, decided by whether the index has room to be a rail
+ // at all. Below 1024px — and on every phone — there is no rail to toggle,
+ // so the same control opens the sheet instead of writing a setting whose
+ // effect nothing on screen would show.
+ if (indexVisible || !(isMobile || narrow)) setView({ indexOpen: !view.indexOpen });
+ else setIndexSheet(true);
+ }, [indexVisible, isMobile, narrow, setView, view.indexOpen]);
+
+ // This surface's own controls, then the shell's. The ones somebody opened the
+ // menu mid-conversation for come first, nearest the button they pressed.
+ const phoneMenu: SurfaceAction[] = React.useMemo(
+ () => [
+ // Things you do to this conversation. Where you can *go* from it — the
+ // trace, the files, the shell, the other sessions — is the bottom bar's
+ // job, and repeating those here would be two answers to one question.
+ { id: 'chat-search', label: 'Search this conversation', icon: 'search', expands: true, group: 'surface', onPress: () => setSearchOpen(true) },
+ { id: 'chat-turns', label: 'Jump to a turn', icon: 'panel-left', active: indexVisible || indexSheet, expands: true, group: 'surface', onPress: openIndex },
+ { id: 'chat-display', label: 'Display settings', icon: 'settings', expands: true, group: 'surface', onPress: () => onOpenSettings?.() },
+ ...(menuActions ?? []).map((action) => ({ ...action, group: 'session' })),
+ ],
+ [indexVisible, indexSheet, openIndex, onOpenSettings, menuActions],
+ );
+
return (
+ // The phone answer, published once for the whole surface. Everything below
+ // — down to a copy button inside a tool call — reads it from here rather
+ // than from a prop threaded through five components, any one of which could
+ // drop it and leave that corner at desktop sizes. See ui/touch.ts.
+ }
aria-label={`${runtimeLabel} chat`}
@@ -537,11 +589,7 @@ export function ChatView({
// rail at all. Below 1024px — and on every phone — there is no rail to
// toggle, so the same button opens the sheet instead of writing a
// setting whose effect nothing on screen would show.
- onToggleIndex={
- indexVisible || !(isMobile || narrow)
- ? () => setView({ indexOpen: !view.indexOpen })
- : () => setIndexSheet(true)
- }
+ onToggleIndex={openIndex}
onOpenSearch={() => setSearchOpen(true)}
onOpenSettings={() => onOpenSettings?.()}
onToggleTheme={onToggleTheme}
@@ -591,6 +639,11 @@ export function ChatView({
position: 'relative',
}}
>
+ {/* Over the conversation, clear of everything else. Anchored to the
+ transcript rather than to the shell: at the shell's bottom right
+ it sat exactly on the send button. */}
+ {isMobile ? : null}
+
{searchOpen ? (
setView({ panelTab })}
- onClose={() => setMobileRail(false)}
+ onClose={() => setView({ panelOpen: false })}
isMobile
/>
) : null}
@@ -767,7 +820,7 @@ export function ChatView({
// want to type the follow-up — while the agent waits on you — was
// the one moment you could not. It queues instead.
disabled={exited || Boolean(unavailable)}
- placeholder={placeholderFor(chatState, runtimeLabel)}
+ placeholder={placeholderFor(chatState, runtimeLabel, isMobile)}
queued={transcript.queuedTurns}
onCancelQueued={cancelQueued}
onFindFiles={findProjectFiles}
@@ -845,6 +898,7 @@ export function ChatView({
+
);
}
@@ -854,15 +908,20 @@ function viewportHeight(): number {
return typeof window === 'undefined' ? 900 : window.innerHeight;
}
-function placeholderFor(state: ChatState, runtimeLabel: string): string {
+function placeholderFor(state: ChatState, runtimeLabel: string, isPhone = false): string {
if (state === 'exited') return 'This session has ended';
// No longer 'answer this before you can type': the composer stays live and
// queues, so the sentence has to describe what happens rather than forbid it.
- if (state === 'awaiting_permission') return 'Answer above — or type the next message';
+ if (state === 'awaiting_permission') {
+ return isPhone ? 'Answer above, or type' : 'Answer above — or type the next message';
+ }
if (state === 'thinking' || state === 'running') {
- return `Queue a follow-up while ${runtimeLabel} works…`;
+ // Short enough for the one line a phone's field is. The long form wrapped
+ // to two, which grew the composer by 26px for a sentence saying what the
+ // ribbon directly above it already says.
+ return isPhone ? 'Queue a follow-up…' : `Queue a follow-up while ${runtimeLabel} works…`;
}
- return `Message ${runtimeLabel}…`;
+ return isPhone ? 'Message…' : `Message ${runtimeLabel}…`;
}
/**
diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx
index 30f4c73..d736b89 100644
--- a/src/client/shell/chat/Composer.tsx
+++ b/src/client/shell/chat/Composer.tsx
@@ -12,6 +12,7 @@ import { mentionAtCaret } from '../../../shared/file-match.js';
import { classifyPaste, MAX_IMAGES_PER_PASTE, PasteCandidate } from '../../../shared/paste-classify.js';
import { MAX_IMAGE_BYTES } from '../../terminal/paste.js';
import { detectMobile } from '../../ui/mobile.js';
+import { PHONE_TEXT, PhoneContext, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js';
import { showNotification } from '../../ui/notifications.js';
import { Icon } from '../../ui/relay/Icon.js';
import { IconButton } from '../../ui/relay/IconButton.js';
@@ -185,7 +186,22 @@ export function Composer({
/** Where to put the caret after a completion rewrites the draft. */
const pendingCaret = React.useRef(null);
- const [isMobile, setIsMobile] = React.useState(safeDetectMobile);
+ /**
+ * The surface's answer wins over this component's own.
+ *
+ * The composer used to decide "am I on a phone?" by calling `detectMobile()`
+ * itself, which made it the app's second source for that answer and put it
+ * out of reach of anything that renders the surface deliberately — a check at
+ * a phone viewport in headless Chrome has no touch points, so the real
+ * composer could never be examined at the size it actually ships at.
+ *
+ * The local detection stays as the fallback: `Composer` is also rendered on
+ * its own, outside any `PhoneContext`, and Enter's two jobs (send versus
+ * newline) still have to be decided correctly there.
+ */
+ const surfaceIsPhone = usePhone();
+ const [detectedMobile, setIsMobile] = React.useState(safeDetectMobile);
+ const isMobile = surfaceIsPhone || detectedMobile;
const [uncontrolledText, setUncontrolledText] = React.useState('');
const [entries, setEntries] = React.useState([]);
const [dragActive, setDragActive] = React.useState(false);
@@ -204,6 +220,15 @@ export function Composer({
* draft — it opens the list and lets the completion do the rewriting.
*/
const [commandsForced, setCommandsForced] = React.useState(false);
+ /**
+ * Whether the phone's secondary controls are showing.
+ *
+ * Attach, the two pickers, the model and the approvals readout are five
+ * controls that a phone has no room for beside the field and no reason to
+ * show while you are typing — the two that matter mid-sentence are send and
+ * stop. Shut by default, and the room goes to the conversation.
+ */
+ const [toolsOpen, setToolsOpen] = React.useState(false);
// A tablet rotated into portrait, or a touch laptop window resized, changes
// which of Enter's two jobs (send vs newline) is correct — see mobile.ts.
@@ -610,8 +635,21 @@ export function Composer({
};
const sendLabel = busy ? 'Queue this message' : 'Send message';
+ /**
+ * The phone's resting shape: the field and its buttons on one line.
+ *
+ * Only while the extra controls are shut. Open, they are five more chips that
+ * have nowhere to go on a shared line, so the column comes back.
+ */
+ const phoneCollapsed = isMobile && !toolsOpen;
return (
+ // Re-published rather than merely consumed: `isMobile` here is the surface's
+ // answer *or* this component's own detection, and every control below —
+ // the chips, the model picker, send and stop — has to size itself from the
+ // same one. Composer mounted on its own on a real phone would otherwise
+ // send with Enter like a phone and draw its buttons like a desktop.
+
@@ -655,6 +693,19 @@ export function Composer({
) : null}
+ {/* One line, not two, while the extra controls are shut.
+ `display: contents` everywhere else, so the desktop keeps the column
+ it has: the field over its own row of actions, which is what stops the
+ buttons floating in the middle of a twelve-line draft. On a phone
+ collapsed there is no twelve-line draft to float in and the second row
+ was 52px the conversation could have had. */}
+
@@ -721,45 +782,91 @@ export function Composer({
field and a row of buttons read as a search box; at twelve the buttons
floated in the middle of a wall of text with nothing to align to.
Actions first, then a line of plain text that says what the keys do
- and what the conversation has cost. */}
-
- {attachmentsEnabled ? (
+ and what the conversation has cost.
+
+ On a phone the first row is send and stop, and everything else is
+ behind the disclosure at its left. */}
+
+ {/* On a phone each of these says what it is. A paperclip is a
+ convention and `@` and `/` are the characters they type, but on a
+ touch screen there is no hover to confirm any of that — the only way
+ to find out what a bare glyph does is to press it and see. */}
+ {isMobile ? (
+ setToolsOpen((value) => !value)}
+ >
+
+
+ ) : null}
+
+ {attachmentsEnabled && (!isMobile || toolsOpen) ? (
fileInputRef.current?.click()}
disabled={disabled}
>
-
+
) : null}
- {filesEnabled ? (
-
- @
+ {filesEnabled && (!isMobile || toolsOpen) ? (
+
+ @
) : null}
- {commands.length > 0 ? (
+ {commands.length > 0 && (!isMobile || toolsOpen) ? (
- /
+ /
) : null}
+ {/* `display: contents` on a phone: the right-hand group stops being a
+ group at all and its controls join the row above, so they wrap into
+ whatever space is left instead of starting a line of their own —
+ which is what left Send sitting alone on a line by itself. */}
- {branch && roomy ? (
+ {branch && roomy && (!isMobile || toolsOpen) ? (
) : null}
- onSetModel?.(value)}
- />
-
-
+ {!isMobile || toolsOpen ? (
+ <>
+ onSetModel?.(value)}
+ />
+
+
+ >
+ ) : null}
- {busy && !disabled ? (
+ {/* Not on a phone's resting row. Stopping is possible exactly while
+ the live ribbon is on screen, and the ribbon carries a labelled
+ stop of its own directly above this — a second one here costs the
+ field about a third of its width to say the same thing twice, and
+ the field is what the row is for. */}
+ {busy && !disabled && !phoneCollapsed ? (
) : null}
+
+ {/* Hidden on a phone until the other controls are, because the session
+ header already carries the cost and this row is otherwise a line of
+ chrome under the one thing you are trying to type into. */}
- {roomy ? hintFor({ isMobile, busy, findFailed, filesEnabled, terminalOpen }) : null}
+ {/* Dropped on a phone. At the phone type size the sentence does not
+ fit the row and truncates to half of itself, and what it was
+ telling you — that `@` picks a file and `/` picks a command — is
+ now written on the two buttons above it. */}
+ {roomy && !isMobile
+ ? hintFor({ isMobile, busy, findFailed, filesEnabled, terminalOpen })
+ : null}
{sessionReadout(turnLabel, usage)}
+
);
}
@@ -933,20 +1064,33 @@ function sessionReadout(turnLabel: string | undefined, usage: ChatUsage | undefi
return bits.join(' · ');
}
-/** A 26px square control on the composer's action row. */
+/**
+ * A control on the composer's action row: a 26px square, or a labelled target
+ * on a phone.
+ *
+ * `text` is drawn only on a phone. On the desktop row the glyph plus a hover
+ * tooltip is enough and three labelled chips would crowd out the field; on a
+ * phone there is no hover, so the label is the only thing that answers "what
+ * does this do" without pressing it.
+ */
function ChipButton({
label,
+ text,
onClick,
disabled,
children,
...rest
}: {
label: string;
+ text?: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
} & React.ButtonHTMLAttributes): React.JSX.Element {
const [hover, setHover] = React.useState(false);
+ const isPhone = usePhone();
+ const labelled = isPhone && Boolean(text);
+ const side = isPhone ? TOUCH_TARGET : 26;
return (
{children}
+ {labelled ? {text} : null}
);
}
@@ -998,6 +1148,7 @@ function Chip({
tone?: string;
children: React.ReactNode;
}): React.JSX.Element {
+ const isPhone = usePhone();
return (
(null);
const inputRef = React.useRef(null);
+ const isPhone = usePhone();
// The session's own model wins. `models` is a menu in whatever order the
// runtime listed it, and its first entry is the current one only by accident.
const matched = models?.find((m) => m.value === current || m.name === current);
@@ -1092,7 +1244,16 @@ function ModelChip({
};
return (
-
+
-
+
This runtime hasn't listed models — type one above.
@@ -1245,14 +1412,15 @@ function ModelChip({
style={{
width: '100%',
marginTop: 2,
- padding: '5px 8px',
+ minHeight: isPhone ? TOUCH_TARGET : undefined,
+ padding: isPhone ? '8px 12px' : '5px 8px',
background: 'transparent',
border: 0,
borderTop: '1px solid var(--border)',
borderRadius: 0,
color: 'var(--muted-foreground)',
font: 'inherit',
- fontSize: 'var(--text-xs)',
+ fontSize: isPhone ? PHONE_TEXT.body : 'var(--text-xs)',
textAlign: 'left',
cursor: 'pointer',
}}
@@ -1279,7 +1447,7 @@ function ModelChip({
feedback.applied === 'live'
? 'var(--foreground)'
: 'var(--muted-foreground)',
- fontSize: 'var(--text-2xs)',
+ fontSize: isPhone ? PHONE_TEXT.body : 'var(--text-2xs)',
zIndex: 'var(--z-dropdown)' as unknown as number,
}}
>
@@ -1332,6 +1500,7 @@ function SendButton({
}): React.JSX.Element {
const [hover, setHover] = React.useState(false);
const [pressed, setPressed] = React.useState(false);
+ const isPhone = usePhone();
return (
-
+
+ {isPhone ? {queueing ? 'Queue' : 'Send'} : null}
);
}
@@ -1384,6 +1559,8 @@ function SendButton({
*/
function StopButton({ onClick, enabled }: { onClick: () => void; enabled: boolean }): React.JSX.Element {
const label = enabled ? 'Stop' : 'This runtime cannot be interrupted';
+ const isPhone = usePhone();
+ const tone = enabled ? { color: 'var(--destructive)', borderColor: 'var(--destructive)' } : undefined;
return (
void; enabled: boolea
label={label}
disabled={!enabled}
onClick={enabled ? onClick : undefined}
- style={enabled ? { color: 'var(--destructive)', borderColor: 'var(--destructive)' } : undefined}
+ style={
+ isPhone
+ ? {
+ ...tone,
+ width: undefined,
+ minWidth: TOUCH_TARGET,
+ height: TOUCH_TARGET,
+ gap: 6,
+ padding: '0 12px',
+ fontFamily: 'var(--font-sans)',
+ fontSize: PHONE_TEXT.body,
+ }
+ : tone
+ }
>
-
+
+ {/* A bare square is not "stop" to anybody who has not been told. */}
+ {isPhone ? Stop : null}
);
}
diff --git a/src/client/shell/chat/MessageBubble.tsx b/src/client/shell/chat/MessageBubble.tsx
index 2f9a6ea..6cc3ea7 100644
--- a/src/client/shell/chat/MessageBubble.tsx
+++ b/src/client/shell/chat/MessageBubble.tsx
@@ -10,6 +10,7 @@ import {
import { ChatTranscript } from '../../chat/transcript.js';
import { compactCount, formatDuration } from '../../chat/tool-meta.js';
import { Icon } from '../../ui/relay/Icon.js';
+import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js';
import { Markdown, markdownText } from './Markdown.js';
import { PlanPanel } from './PlanPanel.js';
@@ -85,6 +86,7 @@ export const MessageBubble = React.memo(function MessageBubble({
);
const [copied, setCopied] = React.useState(false);
+ const isPhone = usePhone();
const isUser = current.role === 'user';
// A marker is not a turn: no surface, no glyph, no controls, and the full
// width of the column — it is a line drawn across the conversation.
@@ -140,6 +142,11 @@ export const MessageBubble = React.memo(function MessageBubble({
aria-label={isUser ? 'Your message' : 'Assistant message'}
style={{
display: 'flex',
+ // On a phone the controls drop to a line of their own below the
+ // message — see the action column. Beside it they were a 44px-wide
+ // column of stacked buttons that made a two-line message four lines
+ // tall and took a sixth of the width off the text.
+ flexWrap: isPhone ? 'wrap' : 'nowrap',
gap: 10,
minWidth: 0,
padding: isUser ? '10px 14px' : '12px 14px',
@@ -191,12 +198,26 @@ export const MessageBubble = React.memo(function MessageBubble({
{isUser ? null : }
-
+
void }): React.JSX.Element {
+ const isPhone = usePhone();
const [hover, setHover] = React.useState(false);
return (
void }): R
alignItems: 'center',
gap: 8,
maxWidth: '100%',
- height: 24,
- padding: '0 8px',
+ height: isPhone ? TOUCH_TARGET : 24,
+ padding: isPhone ? '0 12px' : '0 8px',
background: 'var(--card)',
border: `1px solid ${hover ? 'var(--border-strong)' : 'var(--border)'}`,
borderRadius: 'var(--radius)',
fontFamily: 'var(--font-mono)',
- fontSize: 10.5,
+ fontSize: isPhone ? PHONE_TEXT.body : 10.5,
color: hover ? 'var(--foreground)' : 'var(--muted-foreground)',
cursor: 'pointer',
transition: 'border-color var(--duration-fast), color var(--duration-fast)',
}}
>
-
+
{label}
@@ -537,6 +559,7 @@ function ActionButton({
tone?: string;
}) {
const [hot, setHot] = React.useState(false);
+ const isPhone = usePhone();
return (
-
+
);
}
function Footer({ model, usage }: { model?: string; usage?: ChatUsage }) {
+ const isPhone = usePhone();
const bits: string[] = [];
if (model) bits.push(model);
if (usage) {
@@ -587,7 +611,9 @@ function Footer({ model, usage }: { model?: string; usage?: ChatUsage }) {
flexWrap: 'wrap',
gap: 8,
fontFamily: 'var(--font-mono)',
- fontSize: 'var(--text-2xs)',
+ // The model this answer ran on, and what it cost: the same figures the
+ // header carries, so the same rule applies to them here.
+ fontSize: isPhone ? PHONE_TEXT.label : 'var(--text-2xs)',
color: 'var(--muted-foreground)',
}}
>
diff --git a/src/client/shell/chat/MessageList.tsx b/src/client/shell/chat/MessageList.tsx
index b3367e7..f6ec2d4 100644
--- a/src/client/shell/chat/MessageList.tsx
+++ b/src/client/shell/chat/MessageList.tsx
@@ -4,6 +4,7 @@ import { createStick, BOTTOM_SLACK, type StickHandle } from '../../chat/stick.js
import type { TurnSummary } from '../../chat/turns.js';
import { Button } from '../../ui/relay/Button.js';
import { Icon } from '../../ui/relay/Icon.js';
+import { usePhone } from '../../ui/touch.js';
import { MessageBubble } from './MessageBubble.js';
import { TurnStrip } from './TurnStrip.js';
@@ -104,6 +105,7 @@ export const MessageList = React.forwardRef
const scroller = React.useRef(null);
const content = React.useRef(null);
const [stuck, setStuck] = React.useState(true);
+ const isPhone = usePhone();
// Read off the transcript rather than tracked here. This used to be local
// state cleared only when a page actually prepended a message — so a page
@@ -290,7 +292,16 @@ export const MessageList = React.forwardRef
flex: 1,
// A normal block scroller. Never `justify-content: flex-end` to fake
// bottom alignment — that makes the scrollback unreachable.
- minHeight: 160,
+ //
+ // `0`, not a 160px floor. A flex item cannot shrink below its
+ // min-height, so a floor taller than the space available does not
+ // reserve room — it overflows the column, and the transcript gets
+ // painted over the live ribbon and the composer beneath it. A phone
+ // in landscape has about 160px for the whole conversation once the
+ // header, the ribbon and the composer have taken theirs, so this
+ // was the shape it broke in first. Flex already gives the scroller
+ // every pixel the column can spare, which is what the floor was for.
+ minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
@@ -411,7 +422,17 @@ export const MessageList = React.forwardRef
variant="secondary"
onClick={jumpToLatest}
iconLeft={}
- style={{ pointerEvents: 'auto', height: 34, boxShadow: 'var(--shadow-md)' }}
+ // The key is omitted on a phone, not set to `undefined`: the
+ // style object is spread over the primitive's own, so a present
+ // key wins whatever its value is — `height: undefined` deleted
+ // the touch floor as thoroughly as `height: 34` overrode it, and
+ // left the pill 17px tall. 34 is a desktop choice: `sm` is 26px
+ // and this floats over the transcript, where it has to be found.
+ style={{
+ pointerEvents: 'auto',
+ ...(isPhone ? null : { height: 34 }),
+ boxShadow: 'var(--shadow-md)',
+ }}
>
Jump to latest
diff --git a/src/client/shell/chat/SessionHeader.tsx b/src/client/shell/chat/SessionHeader.tsx
index c95cc59..2fefc62 100644
--- a/src/client/shell/chat/SessionHeader.tsx
+++ b/src/client/shell/chat/SessionHeader.tsx
@@ -5,6 +5,7 @@ import { Icon } from '../../ui/relay/Icon.js';
import { IconButton } from '../../ui/relay/IconButton.js';
import { Kbd } from '../../ui/relay/Kbd.js';
import { Tooltip } from '../../ui/relay/Tooltip.js';
+import { PHONE_SPACE, PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET } from '../../ui/touch.js';
import { UsageMeter } from './UsageMeter.js';
/**
@@ -98,9 +99,6 @@ const SR_ONLY: React.CSSProperties = {
clip: 'rect(0,0,0,0)',
};
-/** Touch-target floor for this app's phone layout; matches IconButton size="lg". */
-const TOUCH = 34;
-
export function SessionHeader({
runtimeLabel,
workingDir,
@@ -126,7 +124,22 @@ export function SessionHeader({
}: SessionHeaderProps): React.JSX.Element {
const meta = exited ? STATE_META.exited : STATE_META[state] || STATE_META.idle;
// A phone is always the tightest case.
- const tight = compact || isMobile;
+ const tight = compact;
+
+ if (isMobile) {
+ return (
+
+ );
+ }
return (
) : null}
- {compact && !isMobile ? null : Beta}
+ {compact ? null : Beta}
{tight ? null : }
@@ -216,7 +224,7 @@ export function SessionHeader({
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
- gap: isMobile ? 4 : 10,
+ gap: 10,
}}
>
{showUsage ? (
@@ -265,7 +273,7 @@ export function SessionHeader({
- {isMobile ? 'bypassed' : 'Approvals bypassed'}
+ Approvals bypassed
) : null}
@@ -303,7 +311,7 @@ export function SessionHeader({
{tight ? (
@@ -314,7 +322,7 @@ export function SessionHeader({
{onToggleTheme ? (
@@ -324,7 +332,7 @@ export function SessionHeader({
@@ -357,6 +365,227 @@ export function SessionHeader({
);
}
+/**
+ * The same bar, laid out for a phone — and mostly not laid out at all.
+ *
+ * A separate component rather than a dozen `isMobile ?` ternaries, because the
+ * answer is not "the same row, smaller". The two layouts disagree about their
+ * shape, and now about how much of themselves they show:
+ *
+ * - The desktop bar is one fixed 34px line that must never wrap, and sheds
+ * items to stay on it.
+ * - The phone bar is a strip saying what the session is doing and what it has
+ * cost, and nothing else until it is asked. Tapping it opens the rest — the
+ * runtime, the folder, the branch, the tokens, the context meter, the
+ * approvals state — at a size worth reading, and tapping it again gives the
+ * room back to the conversation.
+ *
+ * Collapsed by default because the conversation is what a phone is for. The two
+ * figures that stay are the two that change while you watch: what it is doing,
+ * and what that has cost so far.
+ *
+ * The controls that used to sit here are gone from the bar entirely — they are
+ * in the floating menu now, published by ChatView. A row of them here was a row
+ * of chrome the transcript never got back.
+ */
+function PhoneHeader({
+ runtimeLabel,
+ workingDir,
+ branch,
+ usage,
+ capabilities,
+ meta,
+ bypassPermissions,
+ showUsage,
+}: {
+ runtimeLabel: string;
+ workingDir: string;
+ branch?: string;
+ usage: ChatUsage;
+ capabilities: ChatCapabilities;
+ meta: StateMeta;
+ bypassPermissions: boolean;
+ showUsage: boolean;
+}): React.JSX.Element {
+ const [open, setOpen] = React.useState(false);
+ const detailId = React.useId();
+
+ return (
+
+ {/* The whole strip is the control. A 44px button in the corner of it
+ would be a second thing to aim at on a bar one line tall; the bar
+ itself is a far bigger target than any button on it. */}
+ setOpen((value) => !value)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: PHONE_SPACE.inline,
+ width: '100%',
+ // The floor, like every other control. A full-width strip is an easy
+ // thing to hit at any height, but "easy" is not the rule and 10px is
+ // not what the conversation was short of.
+ minHeight: TOUCH_TARGET,
+ padding: `2px ${PHONE_SPACE.edge}px`,
+ background: 'transparent',
+ border: 0,
+ color: 'inherit',
+ font: 'inherit',
+ textAlign: 'left',
+ cursor: 'pointer',
+ WebkitTapHighlightColor: 'transparent',
+ }}
+ >
+
+
+ {meta.label}
+
+
+ {/* Collapsed, the cost is the one figure worth the width. The rest of
+ the meter is below, where there is room for it. */}
+
+ {showUsage && !open ? (
+
+ ) : null}
+
+ {bypassPermissions && !open ? (
+
+ ) : null}
+
+
+
+
+
+ {open ? (
+
{session.workingDir ? (
@@ -92,7 +100,7 @@ function SessionCard({
border: '1px solid var(--border)',
borderRadius: 'var(--radius-full)',
fontFamily: 'var(--font-mono)',
- fontSize: 'var(--text-2xs)',
+ fontSize: isPhone ? PHONE_TEXT.meta : 'var(--text-2xs)',
color: 'var(--muted-foreground)',
}}
>
diff --git a/src/client/ui/relay/Badge.tsx b/src/client/ui/relay/Badge.tsx
index 2c543b7..720a619 100644
--- a/src/client/ui/relay/Badge.tsx
+++ b/src/client/ui/relay/Badge.tsx
@@ -22,13 +22,21 @@ export interface BadgeProps {
dot?: boolean;
children?: React.ReactNode;
style?: React.CSSProperties;
+ /**
+ * A stable name for a badge whose text is written for the space available.
+ *
+ * "Approvals bypassed" and "bypassed" are the same fact at two widths; only
+ * an explicit name lets a screen reader — or a check asserting that the fact
+ * is legible — ask for it without knowing which width was drawn.
+ */
+ 'aria-label'?: string;
}
-export function Badge({ variant = 'neutral', dot = false, children, style }: BadgeProps) {
+export function Badge({ variant = 'neutral', dot = false, children, style, ...rest }: BadgeProps) {
// Fall back to `neutral` so an out-of-range variant still renders a valid badge.
const v = V[variant] || V.neutral;
return (
- setActive(false)}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: s.gap,
- height: s.height, padding: s.padding, fontSize: s.fontSize, fontFamily: 'var(--font-sans)',
+ height: isPhone ? TOUCH_TARGET : s.height,
+ padding: s.padding,
+ fontSize: isPhone ? PHONE_TEXT.body : s.fontSize,
+ fontFamily: 'var(--font-sans)',
fontWeight: 'var(--font-medium)' as React.CSSProperties['fontWeight'], lineHeight: 1, letterSpacing: '0.01em', whiteSpace: 'nowrap',
borderRadius: 'var(--radius)', cursor: disabled ? 'not-allowed' : 'pointer', userSelect: 'none',
transition: 'background var(--duration-fast) var(--ease-standard), filter var(--duration-fast)',
diff --git a/src/client/ui/relay/Dialog.tsx b/src/client/ui/relay/Dialog.tsx
index 3cee34d..9dd7c03 100644
--- a/src/client/ui/relay/Dialog.tsx
+++ b/src/client/ui/relay/Dialog.tsx
@@ -1,4 +1,6 @@
import * as React from 'react';
+
+import { TOUCH_TARGET, usePhone } from '../touch.js';
import { Icon } from './Icon';
export interface DialogProps {
@@ -256,6 +258,7 @@ export function Dialog({
if (!open) return null;
const bottom = placement === 'bottom';
+ const isPhone = usePhone();
const windowed = movable && !bottom;
// Placed only once the user has actually moved or maximised it. Until then
// the overlay's flex centring is left alone.
@@ -321,8 +324,14 @@ export function Dialog({
const headerRowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 };
const titleStyle: React.CSSProperties = { margin: 0, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-body)', fontWeight: 'var(--font-semibold)' as React.CSSProperties['fontWeight'], color: 'var(--foreground)' };
const closeStyle: React.CSSProperties = {
- border: 'none', background: 'transparent', color: 'var(--muted-foreground)', cursor: 'pointer', fontSize: 15, lineHeight: 1, padding: 2,
+ border: 'none', background: 'transparent', color: 'var(--muted-foreground)', cursor: 'pointer', fontSize: bottom || isPhone ? 20 : 15, lineHeight: 1, padding: 2,
borderRadius: 'var(--radius)',
+ // A bottom sheet is always the phone form of this dialog — see `placement`
+ // — and a centred one is too when the app says it is on a phone. Either
+ // way its one control is sized for a thumb rather than a cursor: 17x19px of
+ // glyph is what a finger has to find otherwise, in the corner of the
+ // screen, next to nothing that would forgive a miss.
+ ...(bottom || isPhone ? { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: TOUCH_TARGET, height: TOUCH_TARGET, marginRight: -10 } : null),
boxShadow: closeFocusVisible ? 'var(--shadow-focus)' : undefined,
};
const descriptionStyle: React.CSSProperties = { margin: '6px 0 0', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', color: 'var(--muted-foreground)', lineHeight: 'var(--leading-normal)' };
diff --git a/src/client/ui/relay/IconButton.tsx b/src/client/ui/relay/IconButton.tsx
index d301184..60c499b 100644
--- a/src/client/ui/relay/IconButton.tsx
+++ b/src/client/ui/relay/IconButton.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
+import { TOUCH_TARGET, usePhone } from '../touch.js';
+
export type IconButtonSize = 'sm' | 'md' | 'lg';
export type IconButtonVariant = 'ghost' | 'outline';
@@ -27,7 +29,10 @@ export function IconButton({
...rest
}: IconButtonProps) {
const [hover, setHover] = React.useState(false);
- const dim = SIZES[size] || SIZES.md;
+ const isPhone = usePhone();
+ // Same rule as Button: on a phone the smallest size is the touch floor,
+ // decided once here instead of at forty call sites.
+ const dim = isPhone ? TOUCH_TARGET : SIZES[size] || SIZES.md;
const outlined = variant === 'outline';
return (
= { sm: 28, md: 32, lg: 38 };
export function Select({ options = [], size = 'md', disabled = false, style, ...rest }: SelectProps) {
const [focus, setFocus] = React.useState(false);
+ const isPhone = usePhone();
return (
- ▾
+ ▾
);
}
diff --git a/src/client/ui/touch.ts b/src/client/ui/touch.ts
new file mode 100644
index 0000000..294b2eb
--- /dev/null
+++ b/src/client/ui/touch.ts
@@ -0,0 +1,97 @@
+/**
+ * The phone's sizing rules — type scale, target size, spacing — in one place.
+ *
+ * Why a TypeScript module and not a stylesheet: this app's chrome is styled
+ * with inline `React.CSSProperties` over the Relay custom properties, and a
+ * media query cannot reach an inline style. A `@media (max-width: 480px)` block
+ * would silently apply to nothing on almost every surface this issue is about.
+ *
+ * So the phone scale is a value that components read, gated on the same
+ * `isMobile` the shell already computes (`ui/mobile.ts` → `shellStore` →
+ * `AppShell` → `ChatView` → children). One source for the device answer, one
+ * source for the numbers that follow from it.
+ *
+ * These are minimums, not suggestions. The browser checks assert the resulting
+ * geometry against their own copies of the thresholds — deliberately not by
+ * importing this file, because a check that imports the constants only proves
+ * the app agrees with itself.
+ */
+
+import * as React from 'react';
+
+/**
+ * Type scale for a phone.
+ *
+ * The desktop scale bottoms out at 10px (`--text-2xs`), which is a caption size
+ * for a screen held at desk distance. At arm's length on a phone it is not
+ * readable, and several things set at it — cost, model, the state word — are
+ * exactly what somebody looks at mid-session.
+ *
+ * The rule the acceptance criteria state: nothing carrying live session
+ * information is smaller than the body text. `label` is therefore equal to
+ * `body`, not one step below it, and that is the point of the pair.
+ */
+export const PHONE_TEXT = {
+ /** Conversation prose and anything read as a sentence. */
+ body: 15,
+ /** Live session information: cost, tokens, model, state, approvals, branch. */
+ label: 15,
+ /**
+ * Anything the user types into.
+ *
+ * 16 rather than 15 for one specific reason: iOS Safari zooms the page in
+ * when a focused field is set below 16px, and it does not zoom back out. The
+ * composer's own buttons end up off the right edge of a page the user never
+ * asked to magnify, which is a worse failure than the one the small type was.
+ */
+ input: 16,
+ /** Standing context that is true all session and glanced at, not read. */
+ meta: 13,
+} as const;
+
+/**
+ * The smallest thing a finger is asked to hit, in CSS pixels.
+ *
+ * 44 is the number both platform guidelines land on, and it is a *hit area*,
+ * not an ink size: an icon may be drawn at 20px inside a 44px button. Below
+ * this, on the sizes the desktop chrome uses (26–34px), the intended control
+ * and its neighbour are within one fingertip of each other.
+ */
+export const TOUCH_TARGET = 44;
+
+/**
+ * Clear space between two neighbouring targets.
+ *
+ * Size alone does not fix mistaps: two 44px buttons touching each other still
+ * present one continuous 88px strip with an invisible seam. The gap is what
+ * makes the seam findable.
+ */
+export const TOUCH_GAP = 8;
+
+/** Phone padding steps, so surfaces do not each invent their own. */
+export const PHONE_SPACE = {
+ /** Inside a control, between its icon and its label. */
+ inline: 8,
+ /** A surface's own edge padding. */
+ edge: 12,
+} as const;
+
+/**
+ * Whether this subtree is being drawn on a phone.
+ *
+ * A context rather than a prop because the surfaces that need the answer are
+ * the deepest ones: a copy button inside a tool call inside a message bubble
+ * inside the list. Threading `isMobile` down five levels puts the same
+ * parameter on five component signatures, and the level that forgets to pass it
+ * on is exactly the level that keeps a 22px button — which is how a phone
+ * layout ends up correct in the chrome and unchanged everywhere else.
+ *
+ * Defaults to false, so a component rendered outside a provider (a dialog
+ * mounted at the root, a static render, a unit test) gets the desktop sizes it
+ * had before.
+ */
+export const PhoneContext = React.createContext(false);
+
+export function usePhone(): boolean {
+ return React.useContext(PhoneContext);
+}
diff --git a/src/public/css/relay/relay.css b/src/public/css/relay/relay.css
index c70ac1b..47a78d1 100644
--- a/src/public/css/relay/relay.css
+++ b/src/public/css/relay/relay.css
@@ -45,6 +45,18 @@
to { transform: translateY(0); opacity: 1; }
}
+/* The same arrival without the fade.
+ For anything that must be legible the instant it exists rather than one
+ frame later: a menu whose opacity starts at 0 is a menu that is not there
+ at all if the animation never runs — which is the case under reduced
+ motion, in a browser that drops the first frame, and in the headless
+ Chrome the browser checks use, where it silently measured an invisible
+ panel and reported it as fine. */
+@keyframes relay-rise {
+ from { transform: translateY(8px); }
+ to { transform: translateY(0); }
+}
+
/* The composer's "the agent is working" signal: a highlight travelling along
the top edge of the input. On the edge rather than over the text because the
field stays usable while it runs — you can type the next message into it. */
diff --git a/test/browser/checks.ts b/test/browser/checks.ts
index 3160a6a..334e78b 100644
--- a/test/browser/checks.ts
+++ b/test/browser/checks.ts
@@ -21,6 +21,13 @@ import { DEFAULT_CHAT_VIEW } from '../../src/client/chat/view-settings';
import { createTerminalController, LIVE_SCROLLBACK_LINES } from '../../src/client/terminal/controller';
import { HistoryView } from '../../src/client/terminal/history-view';
import { Dialog } from '../../src/client/ui/relay/Dialog';
+import { PhoneContext } from '../../src/client/ui/touch';
+import { FloatingMenu, type FloatingMenuAction } from '../../src/client/shell/FloatingMenu';
+import { BottomNav } from '../../src/client/shell/BottomNav';
+import { MoreSheet } from '../../src/client/shell/MoreSheet';
+import { ChatSettingsDialog } from '../../src/client/shell/dialogs/ChatSettingsDialog';
+import { SessionsDialog } from '../../src/client/shell/dialogs/SessionsDialog';
+import { TabSwitcherSheet } from '../../src/client/shell/TabSwitcherSheet';
const results: string[] = [];
const check = (name: string, ok: boolean, detail = ''): void => {
@@ -226,6 +233,8 @@ async function run(): Promise {
await checkTheComposerShrinksWithTheWorkspaceRail();
await checkTheFixedBarsNeverWrap();
await checkALiveAnswerAppearsAsItStreams();
+ await checkThePhoneLayoutIsUsable();
+ await checkThePhoneShellSurfacesAreUsable();
const pre = document.createElement('pre');
pre.id = 'results';
@@ -726,9 +735,855 @@ async function checkALiveAnswerAppearsAsItStreams(): Promise {
host.remove();
}
+/* -------------------------------------------------------------------------
+ * The phone (issue #51)
+ *
+ * Every check above runs at a desktop width, which is how the phone layout
+ * shipped for four minor versions as a shrunken desktop: nothing that ran had
+ * a viewport small enough to see it. These thresholds are written out here
+ * rather than imported from `src/client/ui/touch.ts` on purpose — a check that
+ * imports the app's own constants proves the app agrees with itself, not that
+ * a finger can hit the button or that an eye can read the label.
+ * ------------------------------------------------------------------------- */
+
+/** The floor for anything rendered as text on a phone. */
+const PHONE_MIN_TEXT = 12;
+/** Live session information is never set below the body text. */
+const PHONE_LIVE_TEXT = 15;
+/** Hit area, not ink: a 20px glyph in a 44px button passes. */
+const PHONE_TARGET = 44;
+/** Clear space between two neighbouring targets. */
+const PHONE_GAP = 8;
+/**
+ * Above this size on the axis they touch, two neighbours need no gap.
+ *
+ * The gap exists so the seam between two targets is findable. That is a real
+ * problem at the floor — two 44px buttons touching present one 88px strip with
+ * an invisible join — and not one above it: a bottom bar's segments are 78px
+ * wide with a label in the middle of each, which is how every phone in the
+ * world draws a tab bar, and inserting gaps into one would make it read as five
+ * loose buttons rather than one bar.
+ */
+const PHONE_GAP_EXEMPT_AT = PHONE_TARGET * 1.5;
+/**
+ * The most vertical room the chrome may take from the conversation, at rest.
+ *
+ * The point of the floating menu and the collapsing header and composer is that
+ * the transcript gets the screen. Nothing else here would notice a redesign
+ * that quietly gave the chrome its room back — every other rule is about the
+ * chrome being *big enough*, which pushes the other way.
+ *
+ * A pixel budget rather than a share of the screen: the chrome is a fixed
+ * number of pixels, so its share grows as the screen shortens, and a share
+ * would fail in landscape for a layout that had given up nothing at all.
+ *
+ * The budget covers the header strip, the live ribbon, the collapsed composer
+ * and the bottom bar together — about 160px of surface plus the bar's 57. It
+ * does not cover the floating menu, which floats.
+ *
+ * The bar is not counted out for the keyboard-open viewport even though the app
+ * hides it there: that hiding is driven by `visualViewport`, which a fixture in
+ * a sized div cannot move. So this measures the harder case in that one.
+ */
+const PHONE_CHAT_CHROME = 220;
+
+/** Visible, non-decorative, and not a screen-reader-only clone. */
+/** The view a node actually lives in — this page's, or an iframe's. */
+function viewOf(node: Element): Window {
+ return node.ownerDocument?.defaultView ?? window;
+}
+
+function isPainted(node: Element): boolean {
+ const box = node.getBoundingClientRect();
+ if (box.width <= 2 || box.height <= 2) return false;
+ if (node.closest('[aria-hidden="true"]')) return false;
+ const styles = viewOf(node).getComputedStyle(node);
+ return styles.visibility !== 'hidden' && styles.display !== 'none' && styles.opacity !== '0';
+}
+
+/**
+ * The controls, and only the controls.
+ *
+ * A control nested inside another control is dropped: the outer one is what a
+ * finger aims at, and counting both reports a 0px gap between a chip and its
+ * own icon every time.
+ */
+function paintedControls(root: HTMLElement): HTMLElement[] {
+ const rootBox = root.getBoundingClientRect();
+ const all = Array.from(
+ root.querySelectorAll('button, input, select, textarea, [role="tab"], [role="option"], a[href]'),
+ ).filter(isPainted).filter((node) => {
+ // A sheet's scrim is a control by markup and a gesture by intent: it is the
+ // empty half of the screen you tap to dismiss, so it abuts the sheet on
+ // purpose and has no size of its own to meet. Recognised by what it is —
+ // an empty element covering most of the surface — rather than by a name.
+ const box = node.getBoundingClientRect();
+ const empty = !(node.textContent || '').trim() && node.childElementCount === 0;
+ return !(empty && box.width * box.height > rootBox.width * rootBox.height * 0.2);
+ });
+ return all.filter((node) => !all.some((other) => other !== node && other.contains(node)));
+}
+
+/**
+ * A control's laid-out size, ignoring transforms.
+ *
+ * `getBoundingClientRect()` reports the painted box, which a dialog's entrance
+ * animation scales — so a 44px button measures 43.12px for as long as the
+ * animation is mid-flight, and headless Chrome does not always finish one. How
+ * big a control *is* is a layout question; where it is on screen is the one the
+ * rect answers, and that is what the spacing and overflow checks use.
+ */
+function laidOutSize(node: HTMLElement): { width: number; height: number } {
+ return { width: node.offsetWidth, height: node.offsetHeight };
+}
+
+/**
+ * Which of the named live figures has been seen on screen at all.
+ *
+ * A collapsed layout is allowed to hide a figure; it is not allowed to make it
+ * unreachable. Filled as the states are walked, asserted once at the end.
+ */
+const seenLive = new Set();
+
+/** Whether any ancestor up to the host deliberately scrolls this node sideways. */
+function scrollsSideways(node: Element): boolean {
+ for (let at: Element | null = node.parentElement; at; at = at.parentElement) {
+ const overflowX = viewOf(at).getComputedStyle(at).overflowX;
+ if (overflowX === 'auto' || overflowX === 'scroll') return true;
+ }
+ return false;
+}
+
+/**
+ * The nearest ancestor that scrolls this node, if any.
+ *
+ * Two controls in different scroll containers have no fixed distance from each
+ * other — one of them moves when its container is scrolled, and either can be
+ * clipped to a sliver at the boundary. Measuring the space between them says
+ * nothing about whether they can be mistapped for each other.
+ */
+function scrollBoxOf(node: Element): Element | null {
+ for (let at: Element | null = node.parentElement; at; at = at.parentElement) {
+ const styles = viewOf(at).getComputedStyle(at);
+ if (/(auto|scroll)/.test(styles.overflowY) || /(auto|scroll)/.test(styles.overflowX)) return at;
+ }
+ return null;
+}
+
+/** Elements that render a word of their own, with the size that word is set at. */
+function paintedText(root: HTMLElement): Array<{ node: HTMLElement; size: number; text: string }> {
+ const out: Array<{ node: HTMLElement; size: number; text: string }> = [];
+ for (const node of Array.from(root.querySelectorAll('*'))) {
+ const own = Array.from(node.childNodes)
+ .filter((child) => child.nodeType === Node.TEXT_NODE)
+ .map((child) => (child.textContent || '').trim())
+ .join(' ')
+ .trim();
+ if (!own || !isPainted(node)) continue;
+ out.push({ node, size: parseFloat(viewOf(node).getComputedStyle(node).fontSize) || 0, text: own });
+ }
+ return out;
+}
+
+function describe(node: HTMLElement, text?: string): string {
+ const label = (text || node.getAttribute('aria-label') || node.textContent || '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return `${node.tagName.toLowerCase()}${label ? `:${label.slice(0, 24)}` : ''}`;
+}
+
+/**
+ * The chat surface as a phone actually composes it.
+ *
+ * The menu is inside `ChatView` on a phone — it has to be, because it is
+ * anchored above the composer and the shell does not know where that ends — so
+ * this only has to supply what the shell would contribute to it.
+ */
+function PhoneSurface({ controller }: { controller: ChatController }): React.ReactElement {
+ // Real view state, not a frozen object with a no-op setter.
+ //
+ // `DEFAULT_CHAT_VIEW.panelOpen` is true — a desktop preference — and on a
+ // phone the rail *replaces* the transcript, so a fixture that cannot write
+ // the setting back renders a surface with no conversation in it at all and
+ // every measurement below is of the panel. The app clears it on mount; a
+ // fixture that swallows the write never sees that happen.
+ // Seeded shut, which is what the shell hands a phone: `panelOpen` is a
+ // desktop preference and on a phone the rail replaces the conversation, so
+ // AppShell keeps its own session-scoped answer and starts it false. A fixture
+ // that passes the stored default straight through renders the panel and
+ // measures that instead of the conversation.
+ const [view, setView] = React.useState({ ...DEFAULT_CHAT_VIEW, panelOpen: false });
+ const onViewChange = React.useCallback((next: typeof DEFAULT_CHAT_VIEW) => setView(next), []);
+ const go = React.useCallback(
+ (next: Partial) => setView((current) => ({ ...current, ...next })),
+ [],
+ );
+
+ // Both bands: the budget below is about what the chrome costs the
+ // conversation, and the bar is chrome.
+ return React.createElement(
+ 'div',
+ { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' } },
+ // The surface in a box that can shrink, exactly as AppShell holds it. Given
+ // the column directly, `height: 100%` on the surface takes the bar's 56px
+ // as well and the transcript is squeezed to nothing.
+ React.createElement(
+ 'div',
+ { style: { flex: 1, minHeight: 0, display: 'flex' } },
+ React.createElement(ChatView, {
+ controller,
+ runtime: 'claude',
+ runtimeLabel: 'Claude Code',
+ workingDir: '/home/dev/projects/a-rather-deeply-nested-working-directory',
+ isMobile: true,
+ view,
+ onViewChange,
+ // What the shell contributes. The surface adds its own inside.
+ menuActions: [
+ { id: 'new', label: 'New session', icon: 'plus', onPress: () => {} },
+ { id: 'more', label: 'More…', icon: 'ellipsis', expands: true, onPress: () => {} },
+ ],
+ } as never),
+ ),
+ React.createElement(BottomNav, {
+ destinations: [
+ {
+ id: 'chat', label: 'Chat', icon: 'message-square',
+ current: !view.panelOpen && !view.terminalOpen,
+ onGo: () => go({ panelOpen: false, terminalOpen: false }),
+ },
+ {
+ id: 'trace', label: 'Trace', icon: 'list-todo',
+ current: view.panelOpen && view.panelTab === 'trace',
+ onGo: () => go({ panelOpen: true, panelTab: 'trace', terminalOpen: false }),
+ },
+ {
+ id: 'files', label: 'Files', icon: 'hard-drive',
+ current: view.panelOpen && view.panelTab !== 'trace',
+ onGo: () => go({ panelOpen: true, panelTab: 'files', terminalOpen: false }),
+ },
+ {
+ id: 'terminal', label: 'Shell', icon: 'terminal',
+ current: view.terminalOpen,
+ onGo: () => go({ panelOpen: false, terminalOpen: true }),
+ },
+ { id: 'sessions', label: 'Sessions', icon: 'layout-list', onGo: () => {} },
+ ],
+ } as never),
+ );
+}
+
+/**
+ * A phone is not a narrow desktop.
+ *
+ * Three viewports, because the failures differ: portrait is the ordinary case,
+ * the short one stands in for the on-screen keyboard eating half the screen
+ * (headless Chrome has no `visualViewport` to resize, and the layout question —
+ * does the composer survive losing the height — is the same either way), and
+ * landscape is where a row that only wrapped by luck stops wrapping.
+ */
+/**
+ * Every phone rule, against whatever is currently on screen.
+ *
+ * Separate from the mount so it can be run again with a sheet open — see the
+ * loop in the caller. `where` names the state, so a failure says which one.
+ */
+function assertPhoneSurface(host: HTMLElement, where: string, atRest = false): void {
+ const hostBox = host.getBoundingClientRect();
+
+ // 1. Every control a finger is meant to hit.
+ const controls = paintedControls(host);
+ const small = controls.filter((node) => {
+ const box = laidOutSize(node);
+ return box.width < PHONE_TARGET || box.height < PHONE_TARGET;
+ });
+ check(
+ `every control is at least ${PHONE_TARGET}px in ${where}`,
+ small.length === 0,
+ small.length
+ ? small
+ .slice(0, 8)
+ .map((n) => {
+ const b = laidOutSize(n);
+ return `${describe(n)}=${b.width}x${b.height}`;
+ })
+ .join(' | ')
+ : `${controls.length} controls`,
+ );
+
+ // 2. And far enough from the one next to it. Only pairs that actually sit
+ // side by side on the same line can be mistapped for each other.
+ const byLeft = [...controls].sort(
+ (a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left,
+ );
+ const crowded: string[] = [];
+ for (let i = 0; i < byLeft.length; i++) {
+ const a = byLeft[i].getBoundingClientRect();
+ for (let j = i + 1; j < byLeft.length; j++) {
+ const b = byLeft[j].getBoundingClientRect();
+ const sameLine = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > Math.min(a.height, b.height) / 2;
+ if (!sameLine) continue;
+ // Only controls that share a scroll container have a fixed distance from
+ // each other — see scrollBoxOf.
+ if (scrollBoxOf(byLeft[i]) !== scrollBoxOf(byLeft[j])) continue;
+ // Both comfortably over the floor on the axis they meet on — see
+ // PHONE_GAP_EXEMPT_AT.
+ if (a.width >= PHONE_GAP_EXEMPT_AT && b.width >= PHONE_GAP_EXEMPT_AT) continue;
+ const gap = b.left - a.right;
+ // Only the nearest neighbour to the right matters; anything further is
+ // separated by that one.
+ if (gap < -0.5) continue;
+ if (gap < PHONE_GAP - 0.5) {
+ crowded.push(`${describe(byLeft[i])}↔${describe(byLeft[j])}=${Math.round(gap)}px`);
+ }
+ break;
+ }
+ }
+ check(
+ `neighbouring controls are at least ${PHONE_GAP}px apart in ${where}`,
+ crowded.length === 0,
+ crowded.length ? crowded.slice(0, 8).join(' | ') : 'no crowded pairs',
+ );
+
+ // 3. Nothing is set in text too small to read.
+ const texts = paintedText(host);
+ const tiny = texts.filter((t) => t.size < PHONE_MIN_TEXT - 0.01);
+ check(
+ `no text is smaller than ${PHONE_MIN_TEXT}px in ${where}`,
+ tiny.length === 0,
+ tiny.length
+ ? tiny.slice(0, 8).map((t) => `${describe(t.node, t.text)}@${t.size}px`).join(' | ')
+ : `${texts.length} text nodes`,
+ );
+
+ // 4. The live session figures — whichever of them this state is showing.
+ //
+ // Not "all of them, always": the header and the composer collapse now, so
+ // most of these sit behind a disclosure. What has to hold is that when one
+ // is on screen it is legible, and that every one of them is reachable in
+ // *some* state — asserted once at the end, against `seenLive`.
+ const live: Array<[string, HTMLElement | null]> = [
+ ['the state', host.querySelector('header [role="status"]')],
+ ['the cost and tokens', host.querySelector('[aria-label="Session usage"]')],
+ ['the approvals state', host.querySelector('[aria-label="Approvals bypassed"]')],
+ ['the model', host.querySelector('[aria-label="Change model"]')],
+ ];
+ for (const [label, node] of live) {
+ if (!node) continue;
+ seenLive.add(label);
+ const size = parseFloat(viewOf(node).getComputedStyle(node).fontSize) || 0;
+ check(
+ `${label} is at least ${PHONE_LIVE_TEXT}px in ${where}`,
+ size >= PHONE_LIVE_TEXT - 0.01,
+ `${size}px`,
+ );
+ }
+
+ // And the model control shows the model, not the word "model".
+ //
+ // Asserted because the size check above cannot tell the difference: the chip
+ // falls back to a placeholder when nothing reported a model, and a fixture
+ // that fails to deliver one measures the placeholder and passes.
+ const modelChip = host.querySelector('[aria-label="Change model"]') as HTMLElement | null;
+ if (modelChip) {
+ check(
+ `the model control names the model in ${where}`,
+ (modelChip.textContent || '').includes('claude-opus-4-6'),
+ (modelChip.textContent || '').trim() || 'empty',
+ );
+ }
+
+ // 5. And each of them says which control it is.
+ //
+ // The issue's wording is "identified without pressing" — on a touch screen
+ // there is no hover, so `title` reveals nothing and `aria-label` is only
+ // read aloud to somebody who has turned a screen reader on. A drawn word
+ // is the only thing that answers the question for everybody.
+ const header = host.querySelector('header') as HTMLElement | null;
+ const composer = (host.querySelector('textarea')?.parentElement ?? null) as HTMLElement | null;
+ for (const [region, box] of [['the header', header], ['the composer row', composer]] as Array<
+ [string, HTMLElement | null]
+ >) {
+ if (!box) continue;
+ const mute = paintedControls(box).filter((node) => {
+ if (node.tagName === 'TEXTAREA') return false;
+ // A field's placeholder is drawn text saying what the field is for, which
+ // is the same answer a button's label gives.
+ if (node.tagName === 'INPUT' && (node as HTMLInputElement).placeholder.trim()) return false;
+ return !(node.textContent || '').trim();
+ });
+ check(
+ `every control in ${region} is identifiable without pressing it in ${where}`,
+ mute.length === 0,
+ mute.length ? mute.slice(0, 8).map((n) => describe(n)).join(' | ') : 'all labelled',
+ );
+ }
+
+ // 6. Nothing is pushed off the side. Vertical overflow inside the
+ // conversation is scrolling and expected; horizontal overflow is a row
+ // that refused to wrap, which is the defect.
+ const offscreen = Array.from(host.querySelectorAll('*')).filter((node) => {
+ if (!isPainted(node)) return false;
+ // Popovers and sheets are allowed to be positioned relative to the
+ // viewport rather than this host.
+ if (viewOf(node).getComputedStyle(node).position === 'fixed') return false;
+ // Nor is a row that deliberately scrolls sideways off-screen: the workspace
+ // tab strip is a scroller with a way back to what it is hiding. What this
+ // is looking for is content pushed out of a box that does not scroll.
+ if (scrollsSideways(node)) return false;
+ const box = node.getBoundingClientRect();
+ return box.right > hostBox.right + 1 || box.left < hostBox.left - 1;
+ });
+ check(
+ `nothing is pushed off the side in ${where}`,
+ offscreen.length === 0,
+ offscreen.length ? offscreen.slice(0, 8).map((n) => describe(n)).join(' | ') : 'nothing overflowing',
+ );
+
+ // 7. And the conversation has most of the screen.
+ //
+ // Only in the resting state: opening the details or the composer's other
+ // controls is the user asking for that room, and taking it back the moment
+ // they do would make the disclosure useless.
+ if (atRest) {
+ const scroller = host.querySelector('[data-message-id]')?.closest('[style*="overflow"]') as HTMLElement | null;
+ const surface = host.getBoundingClientRect();
+ if (scroller && surface.height > 0) {
+ const chrome = surface.height - scroller.getBoundingClientRect().height;
+ check(
+ `the chrome takes no more than ${PHONE_CHAT_CHROME}px from the conversation in ${where}`,
+ chrome <= PHONE_CHAT_CHROME,
+ `${Math.round(chrome)}px of ${Math.round(surface.height)}px`
+ + ` (${Math.round((1 - chrome / surface.height) * 100)}% left to the conversation)`,
+ );
+ }
+ }
+
+ // 8. The regions do not overlap.
+ //
+ // Every rule above is satisfiable by a layout whose bands sit on top of
+ // each other: a control can be the right size, in the right type, inside
+ // the surface, and still be underneath the ribbon.
+ //
+ // Asserted between the *regions*, not their contents. The conversation is
+ // a scroller, so it clips its own children — a turn scrolled half out of
+ // view has a box that extends above the scroller and is painted nowhere
+ // near there, and comparing that box to the header reports an overlap that
+ // does not exist on screen.
+ const conversation = host.querySelector('[data-message-id]')?.closest('[style*="overflow"]') as HTMLElement | null;
+ const bands: Array<[string, HTMLElement | null]> = [
+ ['the header', host.querySelector('header')],
+ ['the status ribbon', host.querySelector('[role="status"][aria-live="polite"]:not(header *)')],
+ ];
+ for (const [label, band] of bands) {
+ if (!band || !conversation || band.contains(conversation) || conversation.contains(band)) continue;
+ const bandBox = band.getBoundingClientRect();
+ const box = conversation.getBoundingClientRect();
+ check(
+ `the conversation and ${label} do not overlap in ${where}`,
+ box.top >= bandBox.bottom - 1 || box.bottom <= bandBox.top + 1,
+ `conversation ${Math.round(box.top)}-${Math.round(box.bottom)}, ${label} ${Math.round(bandBox.top)}-${Math.round(bandBox.bottom)}`,
+ );
+ }
+
+ // 9. The composer is the one thing that must survive every viewport: a
+ // phone with no way to type is not a degraded layout, it is a dead app.
+ const textarea = host.querySelector('textarea') as HTMLElement | null;
+ if (!textarea) {
+ check(`the composer is reachable in ${where}`, false, 'no textarea');
+ } else {
+ const box = textarea.getBoundingClientRect();
+ check(
+ `the composer is fully on screen in ${where}`,
+ box.top >= hostBox.top - 1 && box.bottom <= hostBox.bottom + 1 && box.height > 0,
+ `composer ${Math.round(box.top)}–${Math.round(box.bottom)}, surface ${Math.round(hostBox.top)}–${Math.round(hostBox.bottom)}`,
+ );
+ }
+
+}
+
+async function checkThePhoneLayoutIsUsable(): Promise {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+
+ const controller = new ChatController('phone-check', { send: () => {} });
+ controller.handle({
+ type: 'chat_snapshot',
+ sessionId: 'phone-check',
+ snapshot: {
+ sessionId: 'phone-check',
+ runtime: 'claude',
+ state: 'running',
+ capabilities: {
+ streaming: true, thinking: true, toolCalls: true, diffs: true, permissions: true,
+ interrupt: true, resume: true, fork: false, attachments: true, usage: true,
+ cost: true, plan: true, commands: [{ name: 'clear' }],
+ models: [{ name: 'claude-opus-4-6', value: 'claude-opus-4-6' }],
+ },
+ usage: { totalTokens: 987654, costUsd: 12.3456, contextWindow: 200000, contextUsed: 150000 },
+ messages: [
+ {
+ id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(),
+ blocks: [{ kind: 'text', text: 'rework the mobile layout so the controls are reachable' }],
+ },
+ {
+ id: 'a1', seq: 2, turnId: 't1', role: 'assistant', ts: Date.now(),
+ blocks: [
+ { kind: 'text', text: 'here is what I changed' },
+ {
+ kind: 'tool', toolId: 'x1', name: 'bash', toolKind: 'execute',
+ status: 'completed', input: { command: 'npm test' }, durationMs: 4321,
+ },
+ ],
+ usage: { inputTokens: 12345, outputTokens: 6789, costUsd: 0.4321 },
+ },
+ ],
+ pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 2,
+ live: true, bypassPermissions: true,
+ },
+ } as never);
+
+ // The model reaches the composer through a `session` event, not through the
+ // snapshot and not through a prop — a `model` field on either is quietly
+ // ignored, which is how a fixture ends up asserting the size of the word
+ // "model" instead of the size of a model name.
+ controller.handle({
+ type: 'chat_event',
+ sessionId: 'phone-check',
+ event: {
+ // seq 3, not 0: the reducer drops anything at or below the cursor the
+ // snapshot left behind, so a seq-0 session event is silently a no-op.
+ t: 'session', seq: 3, ts: Date.now(), model: 'claude-opus-4-6',
+ // Repeated in full: a `session` event replaces the capabilities rather
+ // than merging into them, so a short one here would quietly take the
+ // model list and the interrupt away from everything below.
+ capabilities: {
+ streaming: true, thinking: true, toolCalls: true, diffs: true, permissions: true,
+ interrupt: true, resume: true, fork: false, attachments: true, usage: true,
+ cost: true, plan: true, commands: [{ name: 'clear' }],
+ models: [{ name: 'claude-opus-4-6', value: 'claude-opus-4-6' }],
+ },
+ },
+ } as never);
+
+ const viewports: Array<[string, number, number]> = [
+ ['portrait', 390, 740],
+ ['with the keyboard open', 390, 380],
+ ['landscape', 740, 390],
+ ];
+
+ /**
+ * The base surface, and the same surface with each of the phone's own
+ * overlays open.
+ *
+ * A check only ever covers what its fixture reaches. The trace rail, the turn
+ * index and the model list are all sheets on a phone, and asserting the
+ * closed state alone is how a layout ends up correct in the chrome and
+ * untouched everywhere behind it.
+ */
+ const states: Array<[string, string[], boolean?]> = [
+ ['', []],
+ // Scrolled back, because a control only exists in that state: the
+ // "Jump to latest" pill floats over the transcript when it is not pinned.
+ // It shipped at 34px — a hardcoded height overriding the touch floor its
+ // own primitive applies — and every run that happened to leave the
+ // transcript pinned reported the phone clean.
+ ['scrolled back through the conversation', [], true],
+ // Each is now reached through the floating menu: open it, then press the
+ // row. Driving it the way a thumb does is the only way to know the route
+ // still exists — the controls left the header when the menu arrived.
+ ['with the session details open', ['[aria-label="Show the session details"]']],
+ ['with the other composer controls open', ['[aria-label="Show the other controls"]']],
+ ['with the menu open', ['[aria-label="Open the menu"]']],
+ ];
+
+ for (const [name, width, height] of viewports) {
+ for (const [state, opens, scrollBack] of states) {
+ // A fresh mount per state, not a toggle: closing a sheet is its own
+ // control, and a second click on the opener left the previous sheet up
+ // and reported its offences against every state after it.
+ host.style.cssText = `width:${width}px;height:${height}px;position:absolute;top:0;left:0;display:flex;overflow:hidden`;
+ const root = createRoot(host);
+ root.render(React.createElement(PhoneSurface, { controller }));
+ await wait(400);
+
+ const where = state ? `${name} ${state}` : name;
+
+ if (scrollBack) {
+ const scroller = host.querySelector('[data-message-id]')?.closest('[style*="overflow"]') as HTMLElement | null;
+ if (scroller) {
+ scroller.scrollTop = 0;
+ scroller.dispatchEvent(new Event('scroll'));
+ await wait(250);
+ }
+ }
+
+ let reached = true;
+ for (const selector of opens) {
+ const opener = host.querySelector(selector) as HTMLElement | null;
+ if (!opener) {
+ check(`the phone surface can open ${state} in ${name}`, false, `no ${selector}`);
+ reached = false;
+ break;
+ }
+ opener.click();
+ await wait(250);
+ }
+ if (!reached) {
+ root.unmount();
+ continue;
+ }
+
+ // The menu has to be *legible*, not merely present. It was animating in
+ // from `opacity: 0` and headless Chrome never advanced the frame, so
+ // every rule below skipped the panel as invisible and reported the state
+ // as clean — a check that covered the menu by name and nothing by fact.
+ if (state.includes('menu')) {
+ const panel = host.querySelector('[role="menu"]') as HTMLElement | null;
+ const styles = panel ? viewOf(panel).getComputedStyle(panel) : null;
+ check(
+ `the menu is actually on screen in ${name}`,
+ Boolean(panel) && styles!.opacity === '1' && !/rgba\(.*,\s*0\)/.test(styles!.backgroundColor),
+ panel ? `opacity=${styles!.opacity} background=${styles!.backgroundColor}` : 'no panel',
+ );
+
+ // And the button that dismisses it is still the thing under the finger.
+ // Its scrim and it were on the same layer, so which one a tap reached
+ // was decided by document order.
+ const button = host.querySelector('[aria-label="Close the menu"][aria-haspopup="menu"]') as HTMLElement | null;
+ const box = button?.getBoundingClientRect();
+ const hit = box
+ ? host.ownerDocument.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)
+ : null;
+ check(
+ `the menu button is not covered by its own scrim in ${name}`,
+ Boolean(button) && Boolean(hit) && button!.contains(hit as Node),
+ hit ? `${(hit as HTMLElement).tagName.toLowerCase()}:${(hit as HTMLElement).getAttribute('aria-label') || '?'}` : 'nothing there',
+ );
+ }
+ assertPhoneSurface(host, where, opens.length === 0);
+ root.unmount();
+ }
+ }
+
+ // The bar is a set of destinations, and pressing one has to go there.
+ //
+ // Asserted by driving it, because the parts that could be wrong are all on
+ // the far side of a press: which item paints as current, and whether the
+ // surface actually changed. Static markup shows a bar that looks right and
+ // navigates nowhere.
+ {
+ host.style.cssText = 'width:390px;height:740px;position:absolute;top:0;left:0;display:flex;overflow:hidden';
+ const root = createRoot(host);
+ root.render(React.createElement(PhoneSurface, { controller }));
+ await wait(400);
+
+ const bar = host.querySelector('nav[aria-label="Go to"]') as HTMLElement | null;
+ const current = (): string =>
+ (bar?.querySelector('[aria-current="page"]')?.textContent || '').trim();
+
+ check('the bar starts on the conversation', current() === 'Chat', current() || 'nothing current');
+
+ for (const [label, expect] of [
+ ['Trace', 'a rail'],
+ ['Files', 'a rail'],
+ ['Chat', 'the transcript'],
+ ] as Array<[string, string]>) {
+ const item = Array.from(bar?.querySelectorAll('button') ?? []).find(
+ (node) => (node.textContent || '').trim() === label,
+ ) as HTMLElement | undefined;
+ if (!item) {
+ check(`the bar offers ${label}`, false, 'not found');
+ continue;
+ }
+ item.click();
+ await wait(300);
+
+ check(`pressing ${label} marks it as where you are`, current() === label, current() || 'nothing current');
+
+ const showsRail = Boolean(host.querySelector('aside[aria-label="Workspace"]'));
+ const showsTranscript = Boolean(host.querySelector('[data-message-id]'));
+ check(
+ `pressing ${label} actually shows ${expect}`,
+ expect === 'a rail' ? showsRail : showsTranscript && !showsRail,
+ `rail=${showsRail} transcript=${showsTranscript}`,
+ );
+ }
+
+ root.unmount();
+ }
+
+ // Reachable, not merely legible wherever it happened to be drawn. Collapsing
+ // the header and the composer is what this asserts the price of: every figure
+ // the issue names by hand still has a state that shows it.
+ for (const label of ['the state', 'the cost and tokens', 'the approvals state', 'the model']) {
+ check(
+ `${label} is reachable somewhere on a phone`,
+ seenLive.has(label),
+ seenLive.has(label) ? 'shown' : 'never on screen in any state',
+ );
+ }
+
+ host.remove();
+}
+
run().catch((error: unknown) => {
const pre = document.createElement('pre');
pre.id = 'results';
pre.textContent = `FAIL :: threw :: ${error instanceof Error ? error.stack : String(error)}`;
document.body.appendChild(pre);
});
+
+/**
+ * The chat surface is not the whole phone (issue #51).
+ *
+ * The bottom bar, its more sheet and the tab switcher live in the app shell,
+ * above and beside every conversation — none of them is reachable from a
+ * ChatView fixture, so they need their own mount or they go the way the phone
+ * layout went: untested and therefore unchanged.
+ *
+ * The floating menu is mounted *open*, because shut it is one button and the
+ * question is whether the list behind it can be read and hit.
+ *
+ * The terminal's key strip is deliberately not here. It is the terminal's own
+ * on-screen controls, which issue #51 lists as a non-goal — they were sized
+ * for a thumb when they were added, under their own issue.
+ */
+async function checkThePhoneShellSurfacesAreUsable(): Promise {
+ const noop = (): void => {};
+ const surfaces: Array<[string, () => React.ReactElement]> = [
+ ['the floating menu', () =>
+ React.createElement(OpenFloatingMenu, {
+ actions: [
+ { id: 'search', label: 'Search this conversation', icon: 'search', onPress: noop, expands: true, group: 'surface' },
+ { id: 'trace', label: 'Trace rail', icon: 'panel-right', onPress: noop, toggle: true, active: true, group: 'surface' },
+ { id: 'sessions', label: 'Sessions', icon: 'layout-list', onPress: noop, expands: true, group: 'session' },
+ { id: 'new', label: 'New', icon: 'plus', onPress: noop, group: 'session' },
+ { id: 'more', label: 'More', icon: 'ellipsis', onPress: noop, expands: true, group: 'session' },
+ ],
+ } as never)],
+ ['the more sheet', () =>
+ React.createElement(MoreSheet, {
+ open: true, theme: 'dark', logoutUrl: '/logout', canCloseSession: true,
+ install: { supported: true, reason: null } as never,
+ onInstall: noop, onClose: noop, onReconnect: noop, onClearTerminal: noop,
+ onSwitchMode: noop, onCloseSession: noop, onOpenSettings: noop,
+ onToggleTheme: noop, onRename: noop,
+ } as never)],
+ ['the chat settings dialog', () =>
+ React.createElement(ChatSettingsDialog, {
+ open: true, settings: DEFAULT_CHAT_VIEW, onChange: noop, onClose: noop,
+ } as never)],
+ ['the sessions dialog', () =>
+ React.createElement(SessionsDialog, {
+ open: true,
+ sessions: [
+ { id: 's1', title: 'a session with a fairly long name', runtime: 'claude', workingDir: '/tmp/a' },
+ { id: 's2', title: 'another one', runtime: 'codex', workingDir: '/tmp/b' },
+ ],
+ activeId: 's1', onJoin: noop, onLeave: noop, onDelete: noop, onNew: noop, onClose: noop,
+ } as never)],
+ ['the tab switcher', () =>
+ React.createElement(TabSwitcherSheet, {
+ open: true,
+ tabs: [
+ { id: 't1', title: 'a session with a fairly long name', kind: 'terminal' },
+ { id: 't2', title: 'another one', kind: 'chat' },
+ ],
+ activeId: 't1',
+ onSelect: noop, onCloseTab: noop, onNew: noop, onAllSessions: noop, onClose: noop,
+ } as never)],
+ ];
+
+ for (const [name, render] of surfaces) {
+ // An iframe, not a sized div.
+ //
+ // Two of these four surfaces are `Dialog`s, and a Dialog portals to
+ // `document.body` and positions itself against the *viewport*. Measured
+ // inside this page it would be 800px wide whatever the host div says — so
+ // the one thing being asked ("does it fit a phone?") would be answered
+ // about a desktop. An iframe has a viewport of its own.
+ const frame = document.createElement('iframe');
+ frame.style.cssText = 'width:390px;height:740px;position:absolute;top:0;left:0;border:0';
+ document.body.appendChild(frame);
+ const doc = frame.contentDocument as Document;
+ doc.open();
+ doc.write(
+ ''
+ + ''
+ + ''
+ + '',
+ );
+ doc.close();
+ await wait(150);
+
+ const host = doc.body;
+ const root = createRoot(host);
+ // Inside a provider, because two of these are the app's ordinary dialogs
+ // rather than phone-only surfaces: they take their sizing from the shell's
+ // `isMobile`, the same way every other component below AppShell does, and
+ // outside one they would correctly render at desktop sizes.
+ root.render(React.createElement(PhoneContext.Provider, { value: true }, render()));
+ await wait(300);
+
+ const target = host;
+ const controls = paintedControls(target);
+ if (controls.length === 0) {
+ check(`${name} renders on a phone`, false, 'no controls found');
+ root.unmount();
+ frame.remove();
+ continue;
+ }
+
+ const small = controls.filter((node) => {
+ const box = laidOutSize(node);
+ return box.width < PHONE_TARGET || box.height < PHONE_TARGET;
+ });
+ check(
+ `every control in ${name} is at least ${PHONE_TARGET}px`,
+ small.length === 0,
+ small.length
+ ? small.slice(0, 8).map((n) => {
+ const b = laidOutSize(n);
+ return `${describe(n)}=${b.width}x${b.height}`;
+ }).join(' | ')
+ : `${controls.length} controls`,
+ );
+
+ const tiny = paintedText(target).filter((t) => t.size < PHONE_MIN_TEXT - 0.01);
+ check(
+ `no text in ${name} is smaller than ${PHONE_MIN_TEXT}px`,
+ tiny.length === 0,
+ tiny.length
+ ? tiny.slice(0, 8).map((t) => `${describe(t.node, t.text)}@${t.size}px`).join(' | ')
+ : 'all legible',
+ );
+
+ const offscreen = Array.from(target.querySelectorAll('*')).filter((node) => {
+ if (!isPainted(node) || scrollsSideways(node)) return false;
+ const box = node.getBoundingClientRect();
+ return box.right > 391 || box.left < -1;
+ });
+ check(
+ `nothing in ${name} is pushed off the side`,
+ offscreen.length === 0,
+ offscreen.length ? offscreen.slice(0, 6).map((n) => describe(n)).join(' | ') : 'nothing overflowing',
+ );
+
+ root.unmount();
+ frame.remove();
+ }
+}
+
+/** The floating menu with its own button already pressed. */
+function OpenFloatingMenu({ actions }: { actions: FloatingMenuAction[] }): React.ReactElement {
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ (ref.current?.querySelector('[aria-label="Open the menu"]') as HTMLElement | null)?.click();
+ }, []);
+ return React.createElement(
+ 'div',
+ { ref, style: { position: 'relative', width: '100%', height: '100%' } },
+ React.createElement(FloatingMenu, { actions } as never),
+ );
+}
diff --git a/test/browser/page.html b/test/browser/page.html
index 5ac4af6..239cda7 100644
--- a/test/browser/page.html
+++ b/test/browser/page.html
@@ -2,8 +2,14 @@
+
+
+
-
diff --git a/test/browser/run.js b/test/browser/run.js
index 0c8172c..1c84d82 100755
--- a/test/browser/run.js
+++ b/test/browser/run.js
@@ -50,6 +50,14 @@ const out = execFileSync(
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
+ // Big enough for every fixture to fit inside the viewport.
+ //
+ // The default is 800x600, and the phone checks mount a 390x740 surface —
+ // so a third of it was below the window. Layout is unaffected (the host is
+ // absolutely sized), but anything that asks the *viewport* a question is:
+ // `elementFromPoint` returns null off-screen, which reads as "nothing is
+ // covering this control" for a control that is not on screen at all.
+ '--window-size=1600,1000',
'--virtual-time-budget=20000',
'--dump-dom',
`file://${path.join(dir, 'page.html')}`,
diff --git a/test/chat-trace.test.js b/test/chat-trace.test.js
index 3f853d5..5367085 100644
--- a/test/chat-trace.test.js
+++ b/test/chat-trace.test.js
@@ -155,20 +155,39 @@ describe('SessionHeader', function () {
}
});
- it('drops the desktop-only chrome on a phone but keeps the state and the search', function () {
+ it('is a strip on a phone: what it is doing, what it has cost, and nothing else', function () {
+ // The controls moved to the floating menu and the detail behind a
+ // disclosure. What stays is the pair that changes while you watch.
const html = render('SessionHeader', { ...HEADER, isMobile: true, branch: 'main' });
- assert.ok(!/Search transcript/.test(html), 'the wide trigger has no room');
- assert.ok(/aria-label="Search this conversation"/.test(html), 'but search stays reachable');
+ assert.ok(/\$0\.4210|\$[0-9]/.test(html), 'the cost is on the strip');
+ assert.ok(/aria-live="polite"/.test(html), 'and so is what it is doing');
+
+ assert.ok(!/Search transcript/.test(html), 'the wide search trigger has no room');
+ assert.ok(!/aria-label="Search this conversation"/.test(html), 'search is in the menu now');
assert.ok(!/\^`/.test(html), 'a phone has no Ctrl+`');
+ assert.ok(!/Beta/.test(html), 'the badge says nothing about this session');
+
+ // The detail is not merely absent — it is behind a control that says so.
+ assert.ok(/aria-expanded="false"/.test(html), 'the strip reports itself collapsed');
+ assert.ok(
+ /aria-label="Show the session details"/.test(html),
+ 'and names what opening it gives you',
+ );
});
- it('keeps the money and the context meter on a phone, by wrapping', function () {
- // Dropping them was a straight loss against the surface this replaced —
- // the cost of a session is worth a second row on a 390px bar.
- const html = render('SessionHeader', { ...HEADER, isMobile: true });
- assert.ok(/349k tok/.test(html), 'the token total belongs on a phone too');
- assert.ok(/aria-valuenow="62"/.test(html), 'and so does the context meter');
- assert.ok(/flex-wrap:wrap/.test(html.replace(/\s/g, '')), 'which means the bar has to wrap');
+ it('gives the money, the meter and the approvals state back when asked', function () {
+ // Collapsing is allowed to hide these; it is not allowed to lose them.
+ // Rendered statically the strip is shut, so this asserts the shut contract
+ // and the browser checks drive the disclosure — see `assertPhoneSurface`,
+ // which walks the open state at three phone viewports.
+ const shut = render('SessionHeader', { ...HEADER, isMobile: true });
+ assert.ok(!/349k tok/.test(shut), 'the token total is not on the strip');
+ assert.ok(!/aria-valuenow="62"/.test(shut), 'nor is the context meter');
+
+ // On a desktop none of it was ever hidden, and that must not have changed.
+ const desktop = render('SessionHeader', HEADER);
+ assert.ok(/349k tok/.test(desktop), 'the desktop bar still carries the total');
+ assert.ok(/aria-valuenow="62"/.test(desktop), 'and the context meter');
});
it('still says the surface is beta', function () {
diff --git a/test/chat-view.test.js b/test/chat-view.test.js
index dc245fd..f13bba4 100644
--- a/test/chat-view.test.js
+++ b/test/chat-view.test.js
@@ -473,9 +473,17 @@ describe('ChatView', function () {
plan: [{ text: 'Wire the view', status: 'in_progress' }],
});
- const html = render({ controller, isMobile: true });
+ // Told to show the conversation, which is what a phone is given: whether
+ // the rail is open is the shell's answer now, not a stored preference this
+ // component reads — see AppShell's `phonePanel`, and the shell test that
+ // asserts a phone never opens onto a panel.
+ const html = render({
+ controller,
+ isMobile: true,
+ view: { ...mod.viewSettings.DEFAULT_CHAT_VIEW, panelOpen: false },
+ });
- assert.ok(!html.includes('aria-label="Workspace"'), 'the rail must not open itself on a phone');
+ assert.ok(!html.includes('aria-label="Workspace"'), 'the conversation is what is showing');
assert.ok(html.includes('aria-expanded="false"'), 'plan collapses to a disclosure');
assert.ok(html.includes('0 of 1'), 'the collapsed summary still reports progress');
assert.ok(html.includes('env(safe-area-inset-bottom'), 'composer must clear the home bar');
diff --git a/test/relay-a11y.test.js b/test/relay-a11y.test.js
index dfca9f0..4cec786 100644
--- a/test/relay-a11y.test.js
+++ b/test/relay-a11y.test.js
@@ -208,7 +208,7 @@ describe('shell chrome accessibility', function () {
const contents = [
`export { renderToStaticMarkup } from 'react-dom/server';`,
`export * as React from 'react';`,
- `export { MobileBar } from ${JSON.stringify(path.join(dir, 'MobileBar'))};`,
+ `export { FloatingMenu } from ${JSON.stringify(path.join(dir, 'FloatingMenu'))};`,
`export { MoreSheet } from ${JSON.stringify(path.join(dir, 'MoreSheet'))};`,
`export { Toasts } from ${JSON.stringify(path.join(dir, 'Toasts'))};`,
`export { TabContextMenu } from ${JSON.stringify(path.join(dir, 'TabContextMenu'))};`,
@@ -233,10 +233,10 @@ describe('shell chrome accessibility', function () {
if (shell && shell.__file) fs.rmSync(shell.__file, { force: true });
});
- it('names the mobile bar and every control on it', function () {
- const { renderToStaticMarkup, React, MobileBar } = shell;
+ it('names the floating menu button and says it opens a menu', function () {
+ const { renderToStaticMarkup, React, FloatingMenu } = shell;
const html = renderToStaticMarkup(
- React.createElement(MobileBar, {
+ React.createElement(FloatingMenu, {
actions: [
{ id: 'sessions', label: 'Sessions', icon: 'layout-list', onPress() {} },
{ id: 'esc', label: 'Esc', icon: 'circle-x', onPress() {} },
@@ -244,39 +244,35 @@ describe('shell chrome accessibility', function () {
}),
);
- assert.ok(/aria-label="Session controls"/.test(html), 'the bar itself is named');
- // Every button carries visible text, which is its accessible name. An
- // icon-only bar would announce five unnamed buttons.
- assert.ok(html.includes('>Sessions<') && html.includes('>Esc<'));
- // The icons are decoration next to that text, not the name.
- assert.strictEqual(
- html.split('aria-hidden="true"').length - 1 >= 2,
- true,
- 'icons are hidden from assistive tech',
- );
+ // Shut, the button is the only thing rendered — so it is the only thing
+ // that can carry a name, and an unnamed square is what the whole phone
+ // layout would be reached through.
+ assert.ok(/aria-label="Open the menu"/.test(html), 'the button is named');
+ assert.ok(/aria-haspopup="menu"/.test(html), 'it says it opens a menu');
+ assert.ok(/aria-expanded="false"/.test(html), 'shut reports collapsed');
+ // Nothing behind it is in the document until it is opened, so the labels
+ // must not be announced as though they were on screen.
+ assert.ok(!html.includes('>Sessions<'), 'the rows are not rendered while shut');
});
- it('announces the More button as opening a panel, not as a current section', function () {
- const { renderToStaticMarkup, React, MobileBar } = shell;
- const closed = renderToStaticMarkup(
- React.createElement(MobileBar, {
- actions: [{ id: 'more', label: 'More', icon: 'ellipsis', expands: true, onPress() {} }],
- }),
- );
- const open = renderToStaticMarkup(
- React.createElement(MobileBar, {
- actions: [{
- id: 'more', label: 'More', icon: 'ellipsis', expands: true, active: true, onPress() {},
- }],
+ it('gives every menu row a name, and reports what kind of control it is', function () {
+ const { renderToStaticMarkup, React, FloatingMenu } = shell;
+ // Rendered open by driving the button, which is the only way in: the
+ // component owns its own open state, deliberately, so that a menu cannot
+ // be left standing by a parent that forgot to close it.
+ const html = renderToStaticMarkup(
+ React.createElement(FloatingMenu, {
+ actions: [
+ { id: 'more', label: 'More', icon: 'ellipsis', expands: true, onPress() {} },
+ { id: 'keys', label: 'Keys', icon: 'keyboard', toggle: true, active: true, onPress() {} },
+ ],
}),
);
-
- assert.ok(/aria-haspopup="dialog"/.test(closed));
- assert.ok(/aria-expanded="false"/.test(closed), 'closed sheet reports collapsed');
- assert.ok(/aria-expanded="true"/.test(open), 'open sheet reports expanded');
- // aria-current means "current item in a set" — wrong for a button that
- // opens a sheet, and it was what this reported before.
- assert.ok(!/aria-current/.test(open), 'a disclosure is not a current item');
+ // Shut on a static render. What this asserts is the shut contract; the
+ // open one is asserted in the browser checks, where the button can be
+ // pressed. Both matter and only one of them is reachable from here.
+ assert.ok(/aria-expanded="false"/.test(html));
+ assert.ok(!/aria-current/.test(html), 'a disclosure is not a current item');
});
it('renders the bottom sheet as a real modal dialog', function () {
diff --git a/test/shell-chrome.test.js b/test/shell-chrome.test.js
index 03bc903..b7cb585 100644
--- a/test/shell-chrome.test.js
+++ b/test/shell-chrome.test.js
@@ -23,6 +23,7 @@ before(function () {
`export * as React from 'react';`,
`export { AppShell } from ${JSON.stringify(path.join(ROOT, 'src/client/shell/AppShell'))};`,
`export { shellStore } from ${JSON.stringify(path.join(ROOT, 'src/client/shell/store'))};`,
+ `export { DEFAULT_CHAT_VIEW } from ${JSON.stringify(path.join(ROOT, 'src/client/chat/view-settings'))};`,
].join('\n');
const out = path.join(os.tmpdir(), `shell-chrome-${process.pid}.js`);
@@ -38,6 +39,7 @@ before(function () {
});
mod = require(out);
mod.__file = out;
+ DEFAULTS.chatView = mod.DEFAULT_CHAT_VIEW;
});
after(function () {
@@ -46,6 +48,9 @@ after(function () {
const SETTINGS = { fontSize: 14, theme: 'github-dark', terminalFontFamily: 'jetbrains-mono' };
+/** Filled in `before`, once the bundle exists. */
+const DEFAULTS = {};
+
/** Every action is a no-op; the assertions are about what renders, not what runs. */
function actions() {
return new Proxy(
@@ -79,6 +84,15 @@ function reset(extra) {
tabs: [],
activeId: null,
isMobile: false,
+ // Reset explicitly: the store is shared between these tests, so a case
+ // that puts a conversation on screen leaves one there for every case
+ // after it — which is how the key-strip test started failing for a
+ // reason that had nothing to do with the key strip.
+ chat: {
+ active: false, sessionId: '', controller: null,
+ runtime: '', runtimeLabel: '', workingDir: '',
+ },
+ chatView: DEFAULTS.chatView,
overlay: null,
overlayMessage: '',
errorText: '',
@@ -130,13 +144,45 @@ describe('shell chrome', function () {
);
});
- it('shows the status bar on desktop and the bottom bar on mobile', function () {
+ it('shows the status bar on desktop and the floating menu on mobile', function () {
const desktop = render(reset({ tabs: [tab('a')], activeId: 'a' }));
- assert.ok(!/aria-label="Session controls"/.test(desktop), 'no bottom bar on desktop');
+ assert.ok(!/aria-haspopup="menu"/.test(desktop), 'no floating menu on desktop');
+ // The bottom bar is gone: five slots of permanent chrome along the bottom
+ // edge, on a surface whose whole point is what is above it. What replaced
+ // it is one square button, and everything the bar held is behind it.
const mobile = render(reset({ tabs: [tab('a')], activeId: 'a', isMobile: true }));
- assert.ok(/aria-label="Session controls"/.test(mobile), 'the bottom bar renders on mobile');
- assert.ok(/>Sessions# More|>More', at));
+ assert.ok(/>Chat