From 67f2d6989d629e407cb35ec4d137c9ac79c06249 Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 19:23:27 +0200 Subject: [PATCH 1/9] feat(mobile): a phone type scale and touch targets for the chat surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone layout was the desktop layout, smaller. Nothing that ran had a viewport small enough to see it: every browser check mounted at 900px or wider, so four minor versions shipped with 10px cost figures and 16px controls two pixels apart. - ui/touch.ts holds the phone scale — type steps, a 44px target floor, an 8px gap — in one place, and publishes it through a context so a copy button five components deep gets the same answer as the header. - The session header is a separate layout on a phone rather than the same row with smaller values: one wrapping row of information at a readable size, one row of controls a finger can hit. It drops the Beta badge and the terminal and theme toggles, all of which the more sheet already has. - The composer, the turn strips, the message bubbles and the stream ribbon follow the same scale. The composer no longer detects "phone" itself — it takes the surface's answer, which is what made it impossible to examine at the size it ships at. - test/browser covers three phone viewports (portrait, short, landscape) and asserts geometry rather than intent: target size, neighbour spacing, type size, the named live figures, and nothing pushed off the side. - test/browser/page.html now loads the app's own stylesheets. Without them every `var(--text-2xs)` resolved to nothing and inherited 16px, so a check measuring type size would have read 16px for text shipping at 10. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) --- src/client/shell/chat/ChatView.tsx | 7 + src/client/shell/chat/Composer.tsx | 93 ++++++-- src/client/shell/chat/MessageBubble.tsx | 37 ++- src/client/shell/chat/SessionHeader.tsx | 301 ++++++++++++++++++++++-- src/client/shell/chat/StreamRibbon.tsx | 33 ++- src/client/shell/chat/TurnStrip.tsx | 41 ++-- src/client/shell/chat/UsageMeter.tsx | 21 +- src/client/ui/relay/Badge.tsx | 12 +- src/client/ui/touch.ts | 137 +++++++++++ test/browser/checks.ts | 262 +++++++++++++++++++++ test/browser/page.html | 8 +- 11 files changed, 870 insertions(+), 82 deletions(-) create mode 100644 src/client/ui/touch.ts diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index 77f4afd..3de9d9b 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -16,6 +16,7 @@ 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 { Composer } from './Composer.js'; import { MessageList, type MessageListHandle } from './MessageList.js'; import { messageText } from './MessageBubble.js'; @@ -494,6 +495,11 @@ export function ChatView({ ); 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`} @@ -845,6 +851,7 @@ export function ChatView({
+
); } diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index 30f4c73..b9ebaca 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); @@ -612,6 +628,12 @@ export function Composer({ const sendLabel = busy ? 'Queue this message' : 'Send message'; 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. +
@@ -694,12 +716,19 @@ export function Composer({ background: 'transparent', color: 'var(--foreground)', fontFamily: 'var(--font-sans)', - fontSize: 'var(--text-ui)', + // `input`, not `body` — the extra pixel is what keeps iOS Safari from + // zooming the page when the field takes focus. See PHONE_TEXT. + fontSize: isMobile ? PHONE_TEXT.input : 'var(--text-ui)', lineHeight: 'var(--leading-normal)', // Room to breathe above and below one line of text, which the old // 6px did not give it: the field is the thing everything else on this // surface is arranged around. padding: 'var(--space-1) 0 var(--space-2)', + // The autosize effect writes `height` directly from `scrollHeight`, + // so the floor has to be a `min-height` it cannot undercut: one line + // of 13px text is a 32px box, which on a phone is a smaller thing to + // aim at than any button around it. + minHeight: isMobile ? TOUCH_TARGET : undefined, maxHeight: '40vh', overflowY: 'auto', }} @@ -722,7 +751,19 @@ export function Composer({ 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 ? ( @@ -790,10 +833,14 @@ export function Composer({ style={{ display: 'flex', alignItems: 'center', + flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 10, minWidth: 0, fontFamily: 'var(--font-mono)', - fontSize: 'var(--text-2xs)', + // The right-hand half of this row is the turn number, the token count + // and the cost — live session figures, so on a phone they are set at + // the body size like every other one. + fontSize: isMobile ? PHONE_TEXT.label : 'var(--text-2xs)', color: 'var(--muted-foreground)', }} > @@ -807,6 +854,7 @@ export function Composer({
+ ); } @@ -933,7 +981,7 @@ function sessionReadout(turnLabel: string | undefined, usage: ChatUsage | undefi return bits.join(' · '); } -/** A 26px square control on the composer's action row. */ +/** A square control on the composer's action row: 26px, or a finger on a phone. */ function ChipButton({ label, onClick, @@ -947,6 +995,8 @@ function ChipButton({ children: React.ReactNode; } & React.ButtonHTMLAttributes): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); + const side = isPhone ? TOUCH_TARGET : 26; return ( ); } @@ -1384,6 +1437,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: TOUCH_TARGET, height: TOUCH_TARGET } : tone} > - + ); } diff --git a/src/client/shell/chat/MessageBubble.tsx b/src/client/shell/chat/MessageBubble.tsx index 2f9a6ea..1d3231d 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. @@ -191,12 +193,22 @@ export const MessageBubble = React.memo(function MessageBubble({ {isUser ? null :
}
-
+
void }): React.JSX.Element { + const isPhone = usePhone(); const [hover, setHover] = React.useState(false); return ( ); } function Footer({ model, usage }: { model?: string; usage?: ChatUsage }) { + const isPhone = usePhone(); const bits: string[] = []; if (model) bits.push(model); if (usage) { @@ -587,7 +602,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/SessionHeader.tsx b/src/client/shell/chat/SessionHeader.tsx index c95cc59..015910c 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,28 @@ 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 +230,7 @@ export function SessionHeader({ flex: '0 0 auto', display: 'flex', alignItems: 'center', - gap: isMobile ? 4 : 10, + gap: 10, }} > {showUsage ? ( @@ -265,7 +279,7 @@ export function SessionHeader({ - {isMobile ? 'bypassed' : 'Approvals bypassed'} + Approvals bypassed ) : null} @@ -303,7 +317,7 @@ export function SessionHeader({ {tight ? ( @@ -314,7 +328,7 @@ export function SessionHeader({ {onToggleTheme ? ( @@ -324,7 +338,7 @@ export function SessionHeader({ @@ -357,6 +371,251 @@ export function SessionHeader({ ); } +/** + * The same bar, laid out for a phone (issue #51). + * + * A separate component rather than a dozen `isMobile ?` ternaries, because the + * answer is not "the same row, smaller" — that is what shipped and what this + * issue is about. The two layouts disagree about their shape: + * + * - The desktop bar is one fixed 34px line that must never wrap, and sheds + * items to stay on it. A phone has no width to shed *into*: shedding is how + * the branch, the search field and every label became invisible. + * - So the phone bar spends height instead, which it has. One wrapping row of + * information at a readable size, one row of controls at a size a finger can + * hit. Nothing is dropped for want of room. + * + * What is dropped, deliberately: the Beta badge (it says nothing about this + * session), the terminal toggle and the theme toggle (both are in the bottom + * bar's more sheet, so nothing becomes unreachable — see MoreSheet). + */ +function PhoneHeader({ + runtimeLabel, + workingDir, + branch, + usage, + capabilities, + meta, + bypassPermissions, + showUsage, + railOpen, + indexOpen, + onToggleRail, + onToggleIndex, + onOpenSearch, + onOpenSettings, +}: { + runtimeLabel: string; + workingDir: string; + branch?: string; + usage: ChatUsage; + capabilities: ChatCapabilities; + meta: StateMeta; + bypassPermissions: boolean; + showUsage: boolean; + railOpen: boolean; + indexOpen: boolean; + onToggleRail(): void; + onToggleIndex(): void; + onOpenSearch(): void; + onOpenSettings(): void; +}): React.JSX.Element { + return ( +
+ {/* The readout. Wraps to as many lines as it needs — a second line costs + a phone very little and is the only way every figure stays legible. */} +
+ + {runtimeLabel} + + + + + {basename(workingDir)} + {workingDir} + + + {/* The branch stays on a phone. On the desktop bar it is shed because + the composer row carries it too — but the composer row is exactly + what a phone's keyboard covers. */} + {branch ? ( + + + {branch} + + ) : null} + + + + + {showUsage ? : null} + + {bypassPermissions ? ( + + + + Approvals bypassed + + + ) : null} +
+ + {/* The controls, at a size and a spacing a finger can use. Each keeps its + own icon; the accessible name is what a long-press reads out, and none + of them is now sharing an edge with its neighbour. */} +
+ + + + +
+
+ ); +} + +/** One header control on a phone: a 20px glyph in a target a finger can hit. */ +function PhoneControl({ + label, + icon, + active = false, + onClick, +}: { + label: string; + icon: string; + active?: boolean; + onClick(): void; +}): React.JSX.Element { + return ( + + + + ); +} + /** * The search field that is not a field. * diff --git a/src/client/shell/chat/StreamRibbon.tsx b/src/client/shell/chat/StreamRibbon.tsx index b319762..37b7414 100644 --- a/src/client/shell/chat/StreamRibbon.tsx +++ b/src/client/shell/chat/StreamRibbon.tsx @@ -5,6 +5,7 @@ import type { ChatTranscript } from '../../chat/transcript.js'; import type { TurnSummary } from '../../chat/turns.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 { useActivity } from './use-activity.js'; /** @@ -48,6 +49,7 @@ export function StreamRibbon({ canInterrupt, onInterrupt, }: StreamRibbonProps): React.JSX.Element { + const isPhone = usePhone(); const tone: Tone = state === 'error' ? 'error' : state === 'awaiting_permission' ? 'waiting' : 'working'; const colour = TONE[tone]; @@ -79,10 +81,14 @@ export function StreamRibbon({ flex: '0 0 auto', display: 'flex', alignItems: 'center', - gap: 10, + // What the agent is doing right now, and the one control that stops it. + // Neither may be squeezed onto a 390px line to keep the ribbon 34px. + flexWrap: isPhone ? 'wrap' : 'nowrap', + gap: isPhone ? TOUCH_GAP : 10, minWidth: 0, - height: 34, - padding: '0 14px', + height: isPhone ? undefined : 34, + minHeight: isPhone ? TOUCH_TARGET + 8 : undefined, + padding: isPhone ? '4px 12px' : '0 14px', background: `color-mix(in oklab, ${colour} 10%, transparent)`, borderTop: `1px solid color-mix(in oklab, ${colour} 32%, transparent)`, fontFamily: 'var(--font-sans)', @@ -97,14 +103,17 @@ export function StreamRibbon({ animation: working ? 'relay-spin 900ms linear infinite' : undefined, }} > - + void; }): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); const label = canInterrupt ? 'Stop this turn' : 'This runtime cannot be interrupted'; return ( ); } diff --git a/src/client/shell/chat/TurnStrip.tsx b/src/client/shell/chat/TurnStrip.tsx index 1fa782a..573788a 100644 --- a/src/client/shell/chat/TurnStrip.tsx +++ b/src/client/shell/chat/TurnStrip.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Icon } from '../../ui/relay/Icon.js'; +import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js'; import { formatTurnMeta, turnTime, STATUS_GLYPH, type TurnSummary } from '../../chat/turns.js'; /** @@ -44,6 +45,7 @@ export function TurnStrip({ bodyId, }: TurnStripProps): React.JSX.Element { const past = variant === 'past'; + const isPhone = usePhone(); const meta = formatTurnMeta(turn); const time = turnTime(turn); const glyph = STATUS_GLYPH[turn.status] || STATUS_GLYPH.done; @@ -68,10 +70,16 @@ export function TurnStrip({ zIndex: 2, display: 'flex', alignItems: 'center', - gap: 10, + // On a phone the bar wraps and grows instead of holding 28px: it + // carries a turn number, a time, a duration and a cost, and the only + // way all four fit on one 390px line is at a size none of them can be + // read at. The desktop bar is still a fixed rule that never wraps. + flexWrap: isPhone ? 'wrap' : 'nowrap', + gap: isPhone ? TOUCH_GAP : 10, minWidth: 0, - height: 28, - padding: '0 14px', + height: isPhone ? undefined : 28, + minHeight: isPhone ? TOUCH_TARGET : undefined, + padding: isPhone ? '4px 12px' : '0 14px', // Opaque, deliberately: see the note above. background: past ? 'var(--muted)' : 'var(--secondary)', borderBottom: '1px solid var(--border)', @@ -90,24 +98,24 @@ export function TurnStrip({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: 16, - height: 16, + width: isPhone ? TOUCH_TARGET : 16, + height: isPhone ? TOUCH_TARGET : 16, padding: 0, - margin: '0 -2px 0 -4px', + margin: isPhone ? '0 0 0 -12px' : '0 -2px 0 -4px', background: 'transparent', border: 0, color: 'var(--muted-foreground)', cursor: 'pointer', }} > - + - + - {turn.label} + {turn.label} ) : null} @@ -161,7 +169,9 @@ export function TurnStrip({ overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)', - fontSize: 10, + // The duration and the cost are in here — session figures, so they + // follow the same rule as the ones in the header. + fontSize: isPhone ? PHONE_TEXT.label : 10, color: 'var(--muted-foreground)', }} > @@ -231,6 +241,7 @@ function StripButton({ tone?: string; }): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); return ( ); } diff --git a/src/client/shell/chat/UsageMeter.tsx b/src/client/shell/chat/UsageMeter.tsx index 750dcd0..0d082e4 100644 --- a/src/client/shell/chat/UsageMeter.tsx +++ b/src/client/shell/chat/UsageMeter.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { ChatCapabilities, ChatUsage } from '../../../shared/chat-events.js'; +import { PHONE_TEXT } from '../../ui/touch.js'; /** * Tokens and money for a message, turn or session. @@ -15,6 +16,15 @@ export interface UsageMeterProps { usage: ChatUsage; capabilities: ChatCapabilities; compact?: boolean; + /** + * Size the figures for a phone. + * + * Separate from `compact`, which says how much is written (one line versus + * the full breakdown) rather than how large it is set. On a phone the compact + * form is still the right *content* — and it is the one carrying the cost, + * the single number issue #51 names as too small to read. + */ + phone?: boolean; } interface TokenField { @@ -22,7 +32,7 @@ interface TokenField { value: number; } -export function UsageMeter({ usage, capabilities, compact = false }: UsageMeterProps) { +export function UsageMeter({ usage, capabilities, compact = false, phone = false }: UsageMeterProps) { // A runtime that advertised no usage/cost reporting can still leave a stale // field behind on a reused object; the capability is what says the number is // meant to be trusted, not merely present. @@ -52,7 +62,7 @@ export function UsageMeter({ usage, capabilities, compact = false }: UsageMeterP : 0; const barColor = contextPct >= 90 ? 'var(--destructive)' : contextPct >= 70 ? 'var(--warning)' : 'var(--success)'; - const fontSize = compact ? 'var(--text-2xs)' : 'var(--text-xs)'; + const fontSize = phone ? PHONE_TEXT.label : compact ? 'var(--text-2xs)' : 'var(--text-xs)'; if (compact) { const parts: string[] = []; @@ -65,10 +75,15 @@ export function UsageMeter({ usage, capabilities, compact = false }: UsageMeterP return (
{parts.join(' · ')} : null} {hasContext ? ( - + {Math.round(contextPct)}% ) : null} 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 ( - (isMobile: boolean, onPhone: T, otherwise: T): T { + return isMobile ? onPhone : otherwise; +} + +/** + * 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/test/browser/checks.ts b/test/browser/checks.ts index 3160a6a..e3d5467 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -226,6 +226,7 @@ async function run(): Promise { await checkTheComposerShrinksWithTheWorkspaceRail(); await checkTheFixedBarsNeverWrap(); await checkALiveAnswerAppearsAsItStreams(); + await checkThePhoneLayoutIsUsable(); const pre = document.createElement('pre'); pre.id = 'results'; @@ -726,6 +727,267 @@ 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; + +/** Visible, non-decorative, and not a screen-reader-only clone. */ +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 = window.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 all = Array.from( + root.querySelectorAll('button, input, select, textarea, [role="tab"], [role="option"], a[href]'), + ).filter(isPainted); + return all.filter((node) => !all.some((other) => other !== node && other.contains(node))); +} + +/** 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(window.getComputedStyle(node).fontSize) || 0, text: own }); + } + return out; +} + +function describe(node: HTMLElement, text?: string): string { + const label = text || node.getAttribute('aria-label') || (node.textContent || '').trim(); + return `${node.tagName.toLowerCase()}${label ? `:${label.slice(0, 20)}` : ''}`; +} + +/** + * 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. + */ +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 }, + // On the snapshot, not as a prop: the model and the branch reach the + // composer through the transcript and a workspace fetch respectively, + // and a prop named `model` on ChatView would be quietly ignored. + model: 'claude-opus-4-6', + 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); + + const viewports: Array<[string, number, number]> = [ + ['portrait', 390, 740], + ['with the keyboard open', 390, 380], + ['landscape', 740, 390], + ]; + + for (const [name, width, height] of viewports) { + // A fresh mount per viewport: the surface measures itself to decide which + // zones fit, and re-rendering a live tree into a resized box is not the + // same thing as opening it at that size. + 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(ChatView, { + controller, + runtime: 'claude', + runtimeLabel: 'Claude Code', + workingDir: '/home/dev/projects/a-rather-deeply-nested-working-directory', + isMobile: true, + view: { ...DEFAULT_CHAT_VIEW }, + onViewChange: () => {}, + } as never), + ); + await wait(400); + + const hostBox = host.getBoundingClientRect(); + + // 1. Every control a finger is meant to hit. + const controls = paintedControls(host); + const small = controls.filter((node) => { + const box = node.getBoundingClientRect(); + return box.width < PHONE_TARGET - 0.5 || box.height < PHONE_TARGET - 0.5; + }); + check( + `every control is at least ${PHONE_TARGET}px in ${name}`, + small.length === 0, + small.length + ? small + .slice(0, 8) + .map((n) => { + const b = n.getBoundingClientRect(); + return `${describe(n)}=${Math.round(b.width)}x${Math.round(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; + 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 ${name}`, + 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 ${name}`, + 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 specifically — the ones somebody reads + // mid-session and the issue names one by one. + 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) { + check(`${label} is on screen in ${name}`, false, 'not found'); + continue; + } + const size = parseFloat(window.getComputedStyle(node).fontSize) || 0; + check( + `${label} is at least ${PHONE_LIVE_TEXT}px in ${name}`, + size >= PHONE_LIVE_TEXT - 0.01, + `${size}px`, + ); + } + + // 5. 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 (window.getComputedStyle(node).position === 'fixed') 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 ${name}`, + offscreen.length === 0, + offscreen.length ? offscreen.slice(0, 8).map((n) => describe(n)).join(' | ') : 'nothing overflowing', + ); + + // 6. 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 ${name}`, false, 'no textarea'); + } else { + const box = textarea.getBoundingClientRect(); + check( + `the composer is fully on screen in ${name}`, + 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)}`, + ); + } + + root.unmount(); + } + + host.remove(); +} + run().catch((error: unknown) => { const pre = document.createElement('pre'); pre.id = 'results'; 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 @@ + + + - From c74a2158b9f65b65735342a7ab96c74bd42b878e Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 19:30:22 +0200 Subject: [PATCH 2/9] feat(mobile): carry the phone scale into the sheets behind the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass fixed what a closed phone surface shows. This opens the three sheets a phone actually reaches — the trace rail, the turn index and the model list — and applies the same rules there, because a check only covers what its fixture opens. - Workspace tabs, the activity filter row and its event rows, the turn index and its rows, the tool-call headers and the model list all take the phone type scale and the 44px floor. - The model list anchors to the composer rather than to its chip on a phone: a 200px list hanging off a chip near the right edge of a 390px screen was drawn half off-screen. - Its custom-name field is set at 16px, not 15 — below that, iOS Safari zooms the page as soon as the field takes focus and does not zoom back. The check mounts fresh per state rather than toggling: a second click on the opener left the sheet up and blamed it for every state after. Sideways scrollers and sheet scrims are recognised for what they are — a tab strip that scrolls has a way back to what it hides, and a scrim abuts the sheet on purpose — so neither is reported as crowding. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) --- src/client/shell/chat/ActivityTimeline.tsx | 45 +-- src/client/shell/chat/Composer.tsx | 48 ++- src/client/shell/chat/ToolCallCard.tsx | 12 +- src/client/shell/chat/TurnIndex.tsx | 40 +-- src/client/shell/chat/WorkspacePanel.tsx | 37 ++- test/browser/checks.ts | 330 +++++++++++++-------- 6 files changed, 313 insertions(+), 199 deletions(-) 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 ( ) : null} diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index b9ebaca..2d56385 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -1144,7 +1144,16 @@ function ModelChip({ }; return ( -
+
@@ -192,6 +195,7 @@ function FoldAllButton({ onClick: () => void; }): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); return ( ); } @@ -234,6 +238,7 @@ function TurnRow({ onSelect: () => void; }): React.JSX.Element { const [hover, setHover] = React.useState(false); + const isPhone = usePhone(); const glyph = STATUS_GLYPH[turn.status] || STATUS_GLYPH.done; const number = String(turn.index).padStart(2, '0'); @@ -255,7 +260,8 @@ function TurnRow({ alignItems: 'center', gap: 7, flex: '0 0 auto', - padding: collapsed ? '6px 4px' : '6px 10px', + minHeight: isPhone ? TOUCH_TARGET : undefined, + padding: collapsed ? '6px 4px' : isPhone ? '8px 12px' : '6px 10px', textAlign: 'left', background: active ? 'var(--accent)' : hover ? 'var(--accent)' : 'transparent', border: 0, @@ -271,9 +277,9 @@ function TurnRow({ @@ -288,14 +294,14 @@ function TurnRow({ animation: glyph.spin ? 'relay-spin 900ms linear infinite' : undefined, }} > - + {collapsed ? null : ( (null); const overflowing = useOverflowing(scroller, [tabs.join(' ')]); const [menuOpen, setMenuOpen] = React.useState(false); + const isPhone = usePhone(); // A rail dragged back out until every tab fits again must not leave a menu // hanging under a button that is no longer there. @@ -453,7 +455,7 @@ function RailHeader({ style={{ display: 'flex', alignItems: 'center', - minHeight: 34, + minHeight: isPhone ? TOUCH_TARGET + 8 : 34, borderBottom: '1px solid var(--border)', flex: '0 0 auto', }} @@ -470,8 +472,8 @@ function RailHeader({ minWidth: 0, display: 'flex', alignItems: 'center', - gap: 2, - padding: '0 2px 0 4px', + gap: isPhone ? TOUCH_GAP : 2, + padding: isPhone ? '0 4px 0 8px' : '0 2px 0 4px', overflowX: 'auto', scrollbarWidth: 'none', }} @@ -491,10 +493,11 @@ function RailHeader({ screen that says so, and a tab you cannot see and cannot scroll to is simply gone. This is the way back. */} {overflowing ? ( -
+
) : null} -
- +
+
@@ -670,6 +684,7 @@ function TabButton({ onSelect: () => void; }): React.JSX.Element { const ref = React.useRef(null); + const isPhone = usePhone(); // Five tabs do not fit 320px, so the row scrolls — and a selection restored // from storage could sit outside it, leaving the panel showing content whose @@ -691,15 +706,15 @@ function TabButton({ display: 'inline-flex', alignItems: 'center', gap: 5, - height: 28, - padding: '0 8px', + height: isPhone ? TOUCH_TARGET : 28, + padding: isPhone ? '0 12px' : '0 8px', background: 'transparent', border: 0, borderRadius: 'var(--radius)', color: selected ? 'var(--foreground)' : 'var(--muted-foreground)', boxShadow: selected ? 'inset 0 -2px 0 var(--foreground)' : 'none', font: 'inherit', - fontSize: 'var(--text-xs)', + fontSize: isPhone ? PHONE_TEXT.body : 'var(--text-xs)', fontWeight: selected ? ('var(--font-medium)' as React.CSSProperties['fontWeight']) : undefined, @@ -707,7 +722,7 @@ function TabButton({ whiteSpace: 'nowrap', }} > - + {CHAT_PANEL_LABELS[id]} ); diff --git a/test/browser/checks.ts b/test/browser/checks.ts index e3d5467..9ee63c3 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -764,12 +764,30 @@ function isPainted(node: Element): boolean { * 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(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))); } +/** 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 = window.getComputedStyle(at).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return true; + } + return false; +} + /** 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 }> = []; @@ -786,8 +804,10 @@ function paintedText(root: HTMLElement): Array<{ node: HTMLElement; size: number } function describe(node: HTMLElement, text?: string): string { - const label = text || node.getAttribute('aria-label') || (node.textContent || '').trim(); - return `${node.tagName.toLowerCase()}${label ? `:${label.slice(0, 20)}` : ''}`; + const label = (text || node.getAttribute('aria-label') || node.textContent || '') + .replace(/\s+/g, ' ') + .trim(); + return `${node.tagName.toLowerCase()}${label ? `:${label.slice(0, 24)}` : ''}`; } /** @@ -799,6 +819,136 @@ function describe(node: HTMLElement, text?: string): string { * 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): 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 = node.getBoundingClientRect(); + return box.width < PHONE_TARGET - 0.5 || box.height < PHONE_TARGET - 0.5; + }); + check( + `every control is at least ${PHONE_TARGET}px in ${where}`, + small.length === 0, + small.length + ? small + .slice(0, 8) + .map((n) => { + const b = n.getBoundingClientRect(); + return `${describe(n)}=${Math.round(b.width)}x${Math.round(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; + // A control clipped by a sideways scroller has a box that no longer says + // where it is on screen, so measuring it against one outside the scroller + // reports a gap that is not there. + if (scrollsSideways(byLeft[i]) !== scrollsSideways(byLeft[j])) 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 specifically — the ones somebody reads + // mid-session and the issue names one by one. + 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) { + check(`${label} is on screen in ${where}`, false, 'not found'); + continue; + } + const size = parseFloat(window.getComputedStyle(node).fontSize) || 0; + check( + `${label} is at least ${PHONE_LIVE_TEXT}px in ${where}`, + size >= PHONE_LIVE_TEXT - 0.01, + `${size}px`, + ); + } + + // 5. 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 (window.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', + ); + + // 6. 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); @@ -850,139 +1000,57 @@ async function checkThePhoneLayoutIsUsable(): Promise { ['landscape', 740, 390], ]; - for (const [name, width, height] of viewports) { - // A fresh mount per viewport: the surface measures itself to decide which - // zones fit, and re-rendering a live tree into a resized box is not the - // same thing as opening it at that size. - 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(ChatView, { - controller, - runtime: 'claude', - runtimeLabel: 'Claude Code', - workingDir: '/home/dev/projects/a-rather-deeply-nested-working-directory', - isMobile: true, - view: { ...DEFAULT_CHAT_VIEW }, - onViewChange: () => {}, - } as never), - ); - await wait(400); - - const hostBox = host.getBoundingClientRect(); - - // 1. Every control a finger is meant to hit. - const controls = paintedControls(host); - const small = controls.filter((node) => { - const box = node.getBoundingClientRect(); - return box.width < PHONE_TARGET - 0.5 || box.height < PHONE_TARGET - 0.5; - }); - check( - `every control is at least ${PHONE_TARGET}px in ${name}`, - small.length === 0, - small.length - ? small - .slice(0, 8) - .map((n) => { - const b = n.getBoundingClientRect(); - return `${describe(n)}=${Math.round(b.width)}x${Math.round(b.height)}`; - }) - .join(' | ') - : `${controls.length} controls`, - ); + /** + * 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 | null]> = [ + ['', null], + ['with the trace rail open', '[aria-label="Show the trace rail"]'], + ['with the turn index open', '[aria-label="Show the turn index"]'], + ['with the model list open', '[aria-label="Change model"]'], + ]; - // 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; - 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`); + for (const [name, width, height] of viewports) { + for (const [state, opens] 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(ChatView, { + controller, + runtime: 'claude', + runtimeLabel: 'Claude Code', + workingDir: '/home/dev/projects/a-rather-deeply-nested-working-directory', + isMobile: true, + view: { ...DEFAULT_CHAT_VIEW }, + onViewChange: () => {}, + } as never), + ); + await wait(400); + + const where = state ? `${name} ${state}` : name; + if (opens) { + const opener = host.querySelector(opens) as HTMLElement | null; + if (!opener) { + check(`the phone surface can open the sheet ${state} in ${name}`, false, `no ${opens}`); + root.unmount(); + continue; } - break; - } - } - check( - `neighbouring controls are at least ${PHONE_GAP}px apart in ${name}`, - 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 ${name}`, - 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 specifically — the ones somebody reads - // mid-session and the issue names one by one. - 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) { - check(`${label} is on screen in ${name}`, false, 'not found'); - continue; + opener.click(); + await wait(300); } - const size = parseFloat(window.getComputedStyle(node).fontSize) || 0; - check( - `${label} is at least ${PHONE_LIVE_TEXT}px in ${name}`, - size >= PHONE_LIVE_TEXT - 0.01, - `${size}px`, - ); - } - - // 5. 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 (window.getComputedStyle(node).position === 'fixed') 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 ${name}`, - offscreen.length === 0, - offscreen.length ? offscreen.slice(0, 8).map((n) => describe(n)).join(' | ') : 'nothing overflowing', - ); - // 6. 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 ${name}`, false, 'no textarea'); - } else { - const box = textarea.getBoundingClientRect(); - check( - `the composer is fully on screen in ${name}`, - 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)}`, - ); + assertPhoneSurface(host, where); + root.unmount(); } - - root.unmount(); } host.remove(); From cac009216a57df60063408ae045a4d00db5ac92d Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 19:33:54 +0200 Subject: [PATCH 3/9] feat(mobile): size the shell's own phone surfaces for a thumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom bar, its more sheet and the tab switcher are the app shell's, not the chat surface's, so a ChatView fixture never reaches them. They get their own mount, in an iframe: two of the three are Dialogs, and a Dialog portals to document.body and positions against the viewport — measured in the main page it would be 800px wide whatever the host div said, so the one question being asked would have been answered about a desktop. - The bar's labels were a 10px caption under a 20px glyph; they are now the phone's meta size. It exists only on a phone, so it takes the scale outright rather than asking whether it is on one. - A bottom-placed Dialog is the phone form of a dialog, so its close control is sized for a thumb rather than a cursor — 17x19px of glyph in the corner of the screen was what a finger had to find. - The tab switcher's rows, its per-tab close and its two footer buttons all clear the floor. The terminal's key strip is deliberately untouched and out of the fixture: issue #51 lists the terminal's own on-screen controls as a non-goal. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) --- src/client/shell/MobileBar.tsx | 6 +- src/client/shell/TabSwitcherSheet.tsx | 30 +++++- src/client/ui/relay/Dialog.tsx | 9 +- test/browser/checks.ts | 140 +++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/client/shell/MobileBar.tsx b/src/client/shell/MobileBar.tsx index 0a44f9a..80694a3 100644 --- a/src/client/shell/MobileBar.tsx +++ b/src/client/shell/MobileBar.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Icon } from '../ui/relay/Icon'; +import { PHONE_TEXT } from '../ui/touch'; export interface MobileBarAction { id: string; @@ -81,7 +82,10 @@ export function MobileBar({ actions }: MobileBarProps): React.JSX.Element { color: action.active ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: action.disabled ? 0.4 : 1, fontFamily: 'var(--font-sans)', - fontSize: 'var(--text-2xs)', + // This bar only ever exists on a phone, so it takes the phone scale + // outright rather than asking whether it is on one. 10px labels + // under a 20px glyph were a caption for a control, not its name. + fontSize: PHONE_TEXT.meta, letterSpacing: 'var(--tracking-wide)', cursor: action.disabled ? 'not-allowed' : 'pointer', // A tap that paints a lingering highlight reads as a stuck button. diff --git a/src/client/shell/TabSwitcherSheet.tsx b/src/client/shell/TabSwitcherSheet.tsx index 8b78ffe..e598575 100644 --- a/src/client/shell/TabSwitcherSheet.tsx +++ b/src/client/shell/TabSwitcherSheet.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Button } from '../ui/relay/Button'; import { Dialog } from '../ui/relay/Dialog'; import { Icon } from '../ui/relay/Icon'; +import { TOUCH_TARGET } from '../ui/touch'; import { IconButton } from '../ui/relay/IconButton'; import type { ShellTab } from './store'; @@ -80,6 +81,9 @@ function TabRow({ flexDirection: 'column', justifyContent: 'center', gap: 3, + // Stretching to the row was not enough on its own: a row of one short + // line is 38px tall, which is under what a thumb reliably hits. + minHeight: TOUCH_TARGET, border: 'none', background: 'transparent', color: 'var(--foreground)', @@ -134,8 +138,12 @@ function TabRow({ ) : null} - - + +
); @@ -171,10 +179,24 @@ export function TabSwitcherSheet({ onClose={onClose} footer={
- -
diff --git a/src/client/ui/relay/Dialog.tsx b/src/client/ui/relay/Dialog.tsx index 3cee34d..d0161e1 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 } from '../touch.js'; import { Icon } from './Icon'; export interface DialogProps { @@ -321,8 +323,13 @@ 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 ? 20 : 15, lineHeight: 1, padding: 2, borderRadius: 'var(--radius)', + // A bottom sheet is the phone form of this dialog — see `placement` — so + // 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 ? { 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/test/browser/checks.ts b/test/browser/checks.ts index 9ee63c3..6bf9f72 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -21,6 +21,9 @@ 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 { MobileBar } from '../../src/client/shell/MobileBar'; +import { MoreSheet } from '../../src/client/shell/MoreSheet'; +import { TabSwitcherSheet } from '../../src/client/shell/TabSwitcherSheet'; const results: string[] = []; const check = (name: string, ok: boolean, detail = ''): void => { @@ -227,6 +230,7 @@ async function run(): Promise { await checkTheFixedBarsNeverWrap(); await checkALiveAnswerAppearsAsItStreams(); await checkThePhoneLayoutIsUsable(); + await checkThePhoneShellSurfacesAreUsable(); const pre = document.createElement('pre'); pre.id = 'results'; @@ -748,11 +752,16 @@ const PHONE_TARGET = 44; const PHONE_GAP = 8; /** 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 = window.getComputedStyle(node); + const styles = viewOf(node).getComputedStyle(node); return styles.visibility !== 'hidden' && styles.display !== 'none' && styles.opacity !== '0'; } @@ -782,7 +791,7 @@ function paintedControls(root: HTMLElement): HTMLElement[] { /** 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 = window.getComputedStyle(at).overflowX; + const overflowX = viewOf(at).getComputedStyle(at).overflowX; if (overflowX === 'auto' || overflowX === 'scroll') return true; } return false; @@ -798,7 +807,7 @@ function paintedText(root: HTMLElement): Array<{ node: HTMLElement; size: number .join(' ') .trim(); if (!own || !isPainted(node)) continue; - out.push({ node, size: parseFloat(window.getComputedStyle(node).fontSize) || 0, text: own }); + out.push({ node, size: parseFloat(viewOf(node).getComputedStyle(node).fontSize) || 0, text: own }); } return out; } @@ -904,7 +913,7 @@ function assertPhoneSurface(host: HTMLElement, where: string): void { check(`${label} is on screen in ${where}`, false, 'not found'); continue; } - const size = parseFloat(window.getComputedStyle(node).fontSize) || 0; + 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, @@ -919,7 +928,7 @@ function assertPhoneSurface(host: HTMLElement, where: string): void { if (!isPainted(node)) return false; // Popovers and sheets are allowed to be positioned relative to the // viewport rather than this host. - if (window.getComputedStyle(node).position === 'fixed') return false; + 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. @@ -1062,3 +1071,124 @@ run().catch((error: unknown) => { 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 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 bottom bar', () => + React.createElement(MobileBar, { + actions: [ + { id: 'sessions', label: 'Sessions', icon: 'layers', onPress: noop, expands: true }, + { id: 'keys', label: 'Keys', icon: 'keyboard', onPress: noop, toggle: true }, + { id: 'chat', label: 'Chat', icon: 'message-square', onPress: noop, active: true }, + { id: 'esc', label: 'Esc', icon: 'corner-up-left', onPress: noop }, + { id: 'more', label: 'More', icon: 'ellipsis', onPress: noop, expands: true }, + ], + } 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 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); + root.render(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 = node.getBoundingClientRect(); + return box.width < PHONE_TARGET - 0.5 || box.height < PHONE_TARGET - 0.5; + }); + check( + `every control in ${name} is at least ${PHONE_TARGET}px`, + small.length === 0, + small.length + ? small.slice(0, 8).map((n) => { + const b = n.getBoundingClientRect(); + return `${describe(n)}=${Math.round(b.width)}x${Math.round(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(); + } +} From 28732f657f1d450d79c7fa7e0096312ddc1778eb Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 19:45:42 +0200 Subject: [PATCH 4/9] feat(mobile): label the phone's controls, and stop the transcript overflowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the geometry checks could not see, found by rendering the phone layout and looking at it. The controls were the right size and still unidentifiable: a `panel-left` glyph does not say "turn index" to anybody, and on a touch screen there is no hover to reveal the title that said so — pressing it and watching what happened was the only way to find out. The header's four controls and the composer's now carry the word as well as the glyph, and a check asserts it, because `title` and `aria-label` both answer the question for nobody holding a phone. And the transcript kept `min-height: 160px`. A flex item cannot shrink below its min-height, so on a screen with less than that to spare the floor did not reserve room — it overflowed the column and painted the conversation over the live ribbon and the composer. A phone in landscape has about 160px for the conversation once the header, ribbon and composer have taken theirs, which is where it showed first. Also here: the message actions move to their own line on a phone (beside the text they were a 44px column that made a two-line message four lines tall), the composer's action row flows as one wrapping row instead of leaving Send alone on a line, and the phone hint line goes — at the phone type size it truncated to half a sentence, and what it said is now written on the two buttons above it. The check learned two things while finding these: a control clipped by a scroller has a box that says nothing about where it is painted, and the model chip falls back to the word "model", so a fixture that never delivered one was measuring the placeholder and passing. Closes #51 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 29 +++++- src/client/shell/chat/Composer.tsx | 97 +++++++++++++++---- src/client/shell/chat/MessageBubble.tsx | 21 ++-- src/client/shell/chat/MessageList.tsx | 11 ++- src/client/shell/chat/SessionHeader.tsx | 49 +++++++--- test/browser/checks.ts | 121 ++++++++++++++++++++++-- 6 files changed, 279 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3039af..82c635f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,34 @@ ## [5.2.0] - Unreleased -Nothing yet — this section collects the work merged onto the 5.2.0 branch. +### Changed +- **A phone gets a layout meant for a phone.** The chat surface was the desktop + layout at the same size, so the figures you actually read mid-session — the + cost, the model, the state, whether approvals are bypassed — were set smaller + than the body text, and the controls sat close enough together that hitting + the intended one was luck. The header now spends a second line rather than + shrinking its type or hiding what is on it; every control a finger is meant + to hit is at least 44px with real space around it; and the controls above the + composer and in the header say what they are instead of 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 bottom bar, its more sheet and the + tab switcher. The desktop and tablet layouts are unchanged. + +### Fixed +- **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 sheets 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, that every + control can be identified without pressing it, and that no region is drawn + over another. They also load the app's own stylesheets, without which every + `var(--text-2xs)` resolved to nothing and a check measuring type size would + have read 16px for text that ships at 10. ## [5.1.2] - 2026-07-26 diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index 2d56385..becfae1 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -764,43 +764,56 @@ export function Composer({ minWidth: 0, }} > + {/* 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. */} {attachmentsEnabled ? ( fileInputRef.current?.click()} disabled={disabled} > - + ) : null} {filesEnabled ? ( - - @ + + @ ) : null} {commands.length > 0 ? ( - / + / ) : 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 ? ( - {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)} @@ -981,21 +1000,32 @@ function sessionReadout(turnLabel: string | undefined, usage: ChatUsage | undefi return bits.join(' · '); } -/** A square control on the composer's action row: 26px, or a finger on a phone. */ +/** + * 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 ( ); } @@ -1421,8 +1457,13 @@ function SendButton({ alignItems: 'center', justifyContent: 'center', flex: '0 0 auto', - width: isPhone ? TOUCH_TARGET : 34, + gap: isPhone ? 6 : 0, + width: isPhone ? undefined : 34, + minWidth: TOUCH_TARGET, height: isPhone ? TOUCH_TARGET : 30, + padding: isPhone ? '0 14px' : 0, + fontFamily: 'var(--font-sans)', + fontSize: isPhone ? PHONE_TEXT.body : undefined, borderRadius: 'var(--radius)', border: '1px solid transparent', background: enabled ? 'var(--primary)' : 'var(--muted)', @@ -1439,7 +1480,8 @@ function SendButton({ + ' opacity var(--duration-fast) var(--ease-standard)', }} > - + + {isPhone ? {queueing ? 'Queue' : 'Send'} : null} ); } @@ -1463,9 +1505,24 @@ function StopButton({ onClick, enabled }: { onClick: () => void; enabled: boolea label={label} disabled={!enabled} onClick={enabled ? onClick : undefined} - style={isPhone ? { ...tone, width: TOUCH_TARGET, height: TOUCH_TARGET } : tone} + 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 1d3231d..6cc3ea7 100644 --- a/src/client/shell/chat/MessageBubble.tsx +++ b/src/client/shell/chat/MessageBubble.tsx @@ -142,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', @@ -195,18 +200,22 @@ export const MessageBubble = React.memo(function MessageBubble({
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', diff --git a/src/client/shell/chat/SessionHeader.tsx b/src/client/shell/chat/SessionHeader.tsx index 015910c..87cbeaf 100644 --- a/src/client/shell/chat/SessionHeader.tsx +++ b/src/client/shell/chat/SessionHeader.tsx @@ -556,47 +556,65 @@ function PhoneHeader({ ) : null}
- {/* The controls, at a size and a spacing a finger can use. Each keeps its - own icon; the accessible name is what a long-press reads out, and none - of them is now sharing an edge with its neighbour. */} + {/* The controls, at a size and a spacing a finger can use — and each + says what it is. A `panel-left` glyph does not mean "turn index" to + anybody, and on a touch screen there is no hover to reveal the title + that said so: pressing it and watching what happens was the only way + to find out. */}
- + - +
); } -/** One header control on a phone: a 20px glyph in a target a finger can hit. */ +/** + * One header control on a phone: a glyph, its name, and a target a finger can + * hit. + * + * `label` is still the accessible name and stays the full sentence — "Hide the + * turn index" — while `text` is the one word there is room to paint. They are + * deliberately different lengths for the same control: the drawn word says + * which control this is, the accessible name says what pressing it will do. + */ function PhoneControl({ label, + text, icon, active = false, onClick, }: { label: string; + text: string; icon: string; active?: boolean; onClick(): void; @@ -609,9 +627,18 @@ function PhoneControl({ aria-pressed={active || undefined} active={active} onClick={onClick} - style={{ width: TOUCH_TARGET, height: TOUCH_TARGET }} + style={{ + width: undefined, + minWidth: TOUCH_TARGET, + height: TOUCH_TARGET, + gap: 6, + padding: '0 10px', + fontFamily: 'var(--font-sans)', + fontSize: PHONE_TEXT.meta, + }} > - + + {text} ); } diff --git a/test/browser/checks.ts b/test/browser/checks.ts index 6bf9f72..167916d 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -797,6 +797,22 @@ function scrollsSideways(node: Element): boolean { 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 }> = []; @@ -869,10 +885,9 @@ function assertPhoneSurface(host: HTMLElement, where: string): void { 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; - // A control clipped by a sideways scroller has a box that no longer says - // where it is on screen, so measuring it against one outside the scroller - // reports a gap that is not there. - if (scrollsSideways(byLeft[i]) !== scrollsSideways(byLeft[j])) 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; const gap = b.left - a.right; // Only the nearest neighbour to the right matters; anything further is // separated by that one. @@ -921,7 +936,47 @@ function assertPhoneSurface(host: HTMLElement, where: string): void { ); } - // 5. Nothing is pushed off the side. Vertical overflow inside the + // 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) => { @@ -942,7 +997,34 @@ function assertPhoneSurface(host: HTMLElement, where: string): void { offscreen.length ? offscreen.slice(0, 8).map((n) => describe(n)).join(' | ') : 'nothing overflowing', ); - // 6. The composer is the one thing that must survive every viewport: a + // 7. 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)}`, + ); + } + + // 8. 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) { @@ -977,10 +1059,6 @@ async function checkThePhoneLayoutIsUsable(): Promise { models: [{ name: 'claude-opus-4-6', value: 'claude-opus-4-6' }], }, usage: { totalTokens: 987654, costUsd: 12.3456, contextWindow: 200000, contextUsed: 150000 }, - // On the snapshot, not as a prop: the model and the branch reach the - // composer through the transcript and a workspace fetch respectively, - // and a prop named `model` on ChatView would be quietly ignored. - model: 'claude-opus-4-6', messages: [ { id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(), @@ -1003,6 +1081,29 @@ async function checkThePhoneLayoutIsUsable(): Promise { }, } 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], From c8fcf21ff318aafdee581232b117327525c2f22b Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 19:51:17 +0200 Subject: [PATCH 5/9] feat(mobile): give the shared controls the touch floor, once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial pass. Three things it turned up. The dialogs a phone reaches — chat display settings, the session list — were still drawn for a cursor: 16x19px close, 27px selects, 29px icon buttons. Fixed in `Button`, `Select` and `IconButton` rather than in each dialog, which is what a design system is for: a surface reached from the bottom bar gets the floor without knowing it is on a phone. And the shell publishes the phone answer at its root, so those dialogs — which render beside every conversation rather than inside one — can read it at all. `ui/touch.ts` had three exported helpers and two constants nothing used. A module whose job is to be the one place should hold the rules in force, not a wishlist. `UsageMeter`'s new name was on a plain div, where most screen readers ignore it. It has a role now, so the name lands. The check learned that `getBoundingClientRect()` measures the painted box: a dialog's entrance animation scales it, so a 44px button measured 43.12 for as long as the animation ran — and headless Chrome does not reliably finish one. How big a control is, is a layout question. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) --- src/client/shell/AppShell.tsx | 8 +++ src/client/shell/chat/UsageMeter.tsx | 3 ++ .../shell/dialogs/ChatSettingsDialog.tsx | 4 +- src/client/shell/dialogs/SessionsDialog.tsx | 12 ++++- src/client/ui/relay/Button.tsx | 11 +++- src/client/ui/relay/Dialog.tsx | 12 +++-- src/client/ui/relay/IconButton.tsx | 7 ++- src/client/ui/relay/Select.tsx | 12 +++-- src/client/ui/touch.ts | 40 --------------- test/browser/checks.ts | 51 +++++++++++++++---- 10 files changed, 98 insertions(+), 62 deletions(-) diff --git a/src/client/shell/AppShell.tsx b/src/client/shell/AppShell.tsx index 599afc4..a126863 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'; @@ -414,6 +415,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. +
tab.id === state.activeId && tab.surface !== 'chat')} />
+
); } diff --git a/src/client/shell/chat/UsageMeter.tsx b/src/client/shell/chat/UsageMeter.tsx index 0d082e4..0113919 100644 --- a/src/client/shell/chat/UsageMeter.tsx +++ b/src/client/shell/chat/UsageMeter.tsx @@ -78,6 +78,9 @@ export function UsageMeter({ usage, capabilities, compact = false, phone = false // Named, because it is one of the readouts a phone layout has to keep // legible and there is otherwise no way to ask for it — by role it is // an anonymous div, and by text it is whatever this session has cost. + // The role is what makes the name land: `aria-label` on a plain div is + // ignored by most screen readers. + role="group" aria-label="Session usage" style={{ display: 'flex', diff --git a/src/client/shell/dialogs/ChatSettingsDialog.tsx b/src/client/shell/dialogs/ChatSettingsDialog.tsx index c28446b..9bbe25e 100644 --- a/src/client/shell/dialogs/ChatSettingsDialog.tsx +++ b/src/client/shell/dialogs/ChatSettingsDialog.tsx @@ -12,6 +12,7 @@ import { Dialog } from '../../ui/relay/Dialog'; import { Icon } from '../../ui/relay/Icon'; import { Select } from '../../ui/relay/Select'; import { SettingRow } from '../../ui/relay/SettingRow'; +import { PHONE_TEXT, usePhone } from '../../ui/touch'; import { Switch } from '../../ui/relay/Switch'; /** @@ -219,12 +220,13 @@ export function ChatSettingsDialog({ /** A rule and a caps label, between groups of rows. */ function Section({ children }: { children: React.ReactNode }): React.JSX.Element { + const isPhone = usePhone(); return (
{session.name}
-
+
{clientsLabel(session.connectedClients)} · {new Date(session.created).toLocaleTimeString()}
{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/Button.tsx b/src/client/ui/relay/Button.tsx index ce02dde..03ac45e 100644 --- a/src/client/ui/relay/Button.tsx +++ b/src/client/ui/relay/Button.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; +import { PHONE_TEXT, TOUCH_TARGET, usePhone } from '../touch.js'; + export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'; export type ButtonSize = 'sm' | 'md' | 'lg'; @@ -38,6 +40,10 @@ export function Button({ variant = 'primary', size = 'md', disabled = false, ico const [active, setActive] = React.useState(false); const v = VARIANTS[variant] || VARIANTS.primary; const s = SIZES[size] || SIZES.md; + // On a phone every size steps up to the touch floor. Doing it here rather + // than at each call site is the point of a design system: a dialog reached + // from the bottom bar gets it without knowing it is on a phone. + const isPhone = usePhone(); return ( +
+ + ); +} + +function MenuRow({ + action, + onDone, +}: { + action: FloatingMenuAction; + onDone: () => void; +}): React.JSX.Element { + return ( + + ); +} diff --git a/src/client/shell/MobileBar.tsx b/src/client/shell/MobileBar.tsx deleted file mode 100644 index 80694a3..0000000 --- a/src/client/shell/MobileBar.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from 'react'; - -import { Icon } from '../ui/relay/Icon'; -import { PHONE_TEXT } from '../ui/touch'; - -export interface MobileBarAction { - id: string; - label: string; - icon: string; - onPress(): void; - /** Paints the item as current, the way a native tab bar marks its section. */ - active?: boolean; - /** - * Set when the item opens a panel rather than navigating. `active` alone - * announced "current item in a set", which is not what a button that opens a - * sheet does. - */ - expands?: boolean; - /** - * Set when the item toggles something visible rather than opening a panel - * (e.g. the key strip). It is announced as pressed/not-pressed — neither a - * dialog disclosure nor a current section. - */ - toggle?: boolean; - /** Draws attention without a count, e.g. background output arrived. */ - badge?: boolean; - disabled?: boolean; -} - -export interface MobileBarProps { - actions: MobileBarAction[]; -} - -/** - * The mobile bottom bar. - * - * Fixed to the bottom edge with a safe-area inset, five items wide, icon over - * label — the shape a phone user already knows. It replaces the hamburger - * drawer and the two floating buttons that used to sit over the terminal: on a - * phone the agent's own output is the thing you are reading, and controls - * floating on top of it hid the last lines exactly when they mattered. - * - * 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 function MobileBar({ actions }: MobileBarProps): React.JSX.Element { - return ( - - ); -} diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index 3de9d9b..60e8df8 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -17,6 +17,7 @@ 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'; @@ -73,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; } /** @@ -103,6 +127,7 @@ export function ChatView({ onViewChange, theme, onToggleTheme, + menuActions, }: ChatViewProps) { const transcript = controller.transcript; @@ -494,6 +519,29 @@ 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( + () => [ + { id: 'chat-search', label: 'Search this conversation', icon: 'search', expands: true, group: 'surface', onPress: () => setSearchOpen(true) }, + { id: 'chat-turns', label: 'Turn index', icon: 'panel-left', active: indexVisible || indexSheet, toggle: true, group: 'surface', onPress: openIndex }, + { id: 'chat-trace', label: 'Trace rail', icon: 'panel-right', active: railOpen, toggle: true, group: 'surface', onPress: toggleRail }, + { id: 'chat-terminal', label: 'Terminal', icon: 'terminal', active: terminalOpen, toggle: true, group: 'surface', onPress: toggleTerminal }, + { id: 'chat-display', label: 'Display settings', icon: 'settings', expands: true, group: 'surface', onPress: () => onOpenSettings?.() }, + ...(menuActions ?? []).map((action) => ({ ...action, group: 'session' })), + ], + [indexVisible, indexSheet, railOpen, terminalOpen, openIndex, toggleRail, toggleTerminal, 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 @@ -543,11 +591,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} @@ -597,6 +641,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 ? ( ) : 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. */} +