Mobile: a layout meant for a phone (#51)#52
Merged
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…rflowing 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements a phone-specific chat/shell layout (issue #51) by centralizing touch sizing rules in a shared UI module and applying them across the chat surface and related sheets/dialogs. It also significantly expands browser checks to validate phone viewport geometry (text legibility, target sizes, spacing, and non-overlap) and ensures the test harness loads the app’s real stylesheets.
Changes:
- Introduces a shared
PhoneContextand phone sizing constants (PHONE_TEXT,TOUCH_TARGET,TOUCH_GAP) and applies them across key UI components (header, composer, ribbons, panels, cards, dialogs). - Adds robust phone-viewport browser checks (multiple viewports + overlay states) and updates the browser test page to load the production CSS.
- Fixes a flexbox min-height issue that caused the transcript to overlap the ribbon/composer on short screens.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/browser/page.html | Loads the app’s compiled Relay + main CSS for accurate font/token measurements in browser checks. |
| test/browser/checks.ts | Adds phone viewport geometry checks for both chat surface and shell-mounted surfaces (dialogs/sheets). |
| src/client/ui/touch.ts | Centralizes phone type scale, touch target/gap constants, and provides PhoneContext + usePhone(). |
| src/client/ui/relay/Select.tsx | Adapts select height/font sizing for phone contexts to meet touch/legibility requirements. |
| src/client/ui/relay/IconButton.tsx | Ensures icon buttons meet the touch target floor on phone via usePhone(). |
| src/client/ui/relay/Dialog.tsx | Sizes the dialog close control for thumb use when rendered as a bottom sheet or on phone. |
| src/client/ui/relay/Button.tsx | Steps all buttons up to the phone touch/typography floor inside a phone context. |
| src/client/ui/relay/Badge.tsx | Allows stable aria-label naming for variable-width badge text (used in phone header checks/a11y). |
| src/client/shell/TabSwitcherSheet.tsx | Ensures tab rows and controls meet touch target sizing within the phone-only sheet. |
| src/client/shell/MobileBar.tsx | Increases mobile bar label type size to the phone scale. |
| src/client/shell/dialogs/SessionsDialog.tsx | Adjusts secondary text/chip font sizes for phone readability via usePhone(). |
| src/client/shell/dialogs/ChatSettingsDialog.tsx | Adjusts section label sizing for phone readability via usePhone(). |
| src/client/shell/chat/WorkspacePanel.tsx | Enlarges workspace rail header/tabs controls and spacing for phone touch targets. |
| src/client/shell/chat/UsageMeter.tsx | Adds a phone sizing mode and stable accessible naming for session usage readout. |
| src/client/shell/chat/TurnStrip.tsx | Makes the strip wrap and increases control sizing/type for phone legibility and reachability. |
| src/client/shell/chat/TurnIndex.tsx | Enlarges index controls and row sizing/type for phone touch targets. |
| src/client/shell/chat/ToolCallCard.tsx | Increases tool card header sizing and type scale for phone. |
| src/client/shell/chat/StreamRibbon.tsx | Makes ribbon wrap and enlarges controls/type for phone; adjusts “stop” labeling for touch. |
| src/client/shell/chat/SessionHeader.tsx | Splits out a dedicated phone header layout with labeled controls and legible live figures. |
| src/client/shell/chat/MessageList.tsx | Fixes min-height behavior so the transcript can shrink without overlapping other regions. |
| src/client/shell/chat/MessageBubble.tsx | Moves message actions to a dedicated row and increases touch targets/type for phone. |
| src/client/shell/chat/Composer.tsx | Publishes phone context for composer subtree; wraps rows on phone; increases target sizes and adds labels. |
| src/client/shell/chat/ChatView.tsx | Publishes phone context once for the whole chat surface to ensure consistent sizing down-tree. |
| src/client/shell/chat/ActivityTimeline.tsx | Enlarges timeline chips/rows/type and enables wrapping for phone. |
| src/client/shell/AppShell.tsx | Publishes phone context at the shell level so dialogs/sheets also render with phone sizing. |
| CHANGELOG.md | Adds a 5.2.0 entry describing the phone layout rework, overflow fix, and expanded browser checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+764
to
+768
| const box = node.getBoundingClientRect(); | ||
| if (box.width <= 2 || box.height <= 2) return false; | ||
| if (node.closest('[aria-hidden="true"]')) return false; | ||
| const styles = viewOf(node).getComputedStyle(node); | ||
| return styles.visibility !== 'hidden' && styles.display !== 'none' && styles.opacity !== '0'; |
…n back Reworked after seeing the first pass on a real phone. Every rule from it still holds; what changed is how much of the screen the chrome is allowed to hold while holding them. - The bottom bar is gone. Five slots along the bottom edge cost 56px plus the safe-area inset of a surface whose whole point is what is above them, and could only ever show five things — so everything else lived in a sheet reached through it anyway. `FloatingMenu` is one 56px square in the bottom right and a labelled list behind it. The header's four controls join it there, so it is one menu rather than two. - The session header is a strip: what it is doing, what it has cost. Tap it for the runtime, the folder, the branch, the tokens, the context meter and the approvals state. - The composer is the field, More, and send — on one line. The rest is behind More. Its stop is gone from a phone: stopping is possible exactly while the ribbon above it is on screen, and the ribbon has a labelled one. - The ribbon holds one line and drops the elapsed/token figures rather than wrapping to two. The conversation went from 43% of a 390x844 screen to 79%, and the chrome from 215px to 159px at every viewport — asserted, because nothing else here would have noticed it drifting back: every other rule is about the chrome being big enough, which pushes the other way. Three things this turned up that were not about the redesign: - The menu animated in from `opacity: 0`, and headless Chrome never advanced the frame — so every rule silently skipped the panel as invisible and reported the state clean. It rises without fading now, which also means it is there under reduced motion and on a dropped first frame. The check asserts it is actually on screen. - The menu button and its own scrim were both at `--z-overlay`, so which one a tap reached came down to document order. Said outright now, and asserted by what `elementFromPoint` returns at the button's centre. - The browser checks ran in an 800x600 window while mounting a 390x740 fixture, so a third of it was off-viewport and anything asking the viewport a question got the wrong answer. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The redesign superseded half of what the first entry described — the header's control row and the bottom bar it referred to are both gone — so the section described two layouts, neither of which shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar the redesign removed held Sessions, New, Panel, Image and More — a place to go, a thing to make, a panel to toggle, a file to attach and a sheet of everything left over. Five slots of the most valuable chrome in the app, spent on a list with no idea in it. Removing it was the wrong answer to that; rethinking what it is for is the right one. What this app does is run agent sessions, and inside one there are four places worth being — the conversation, what the agent did about it, the files it did that to, and a shell in the same directory — plus the other sessions. `BottomNav` is those five, it paints which one you are on with `aria-current="page"` and a rule that survives monochrome, and pressing one goes there. The verbs stay on the floating button: a control that changes where you are and a control that does something to where you are do not belong in the same row. It hides while the on-screen keyboard is up — the keyboard takes half the screen and the half it leaves is the half being typed into, which is the one moment nobody is navigating. Making the bar able to say where you are meant one answer for the rail rather than two, and that surfaced a real bug behind the local state that had been hiding it: `panelOpen` is persisted, and on a phone the rail *replaces* the conversation, so the stored preference opened every conversation behind a panel. My first fix cleared it on mount — which writes the phone's answer into a shared setting and closes the rail on the desktop that set it open. It is session state held by the shell now, and the preference is not touched. The chrome costs 216px with the bar, against 159 without and 215 before any of this — so the conversation keeps about seven tenths of a phone screen rather than eight, and the bar is worth that. Two more fixture lessons, both the same shape as the last: the browser fixture has to compose the surface the way the shell does, or it measures something the app never renders. It passed `DEFAULT_CHAT_VIEW` straight through with a no-op setter and spent a whole run measuring the workspace panel instead of the conversation; and the shell's own tests shared a store without resetting `chat`, so a case that put a conversation on screen broke the key-strip case three tests later. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loor CI caught it on Node 22 and not on Node 24, which is the tell: the pill only exists while the transcript is *not* pinned to the bottom, so whether a run saw it came down to timing. The check now scrolls the conversation back on purpose, which is the state that control lives in. Two ways to defeat a primitive's touch floor from a call site, and this hit both. `height: 34` overrode it outright. Replacing that with `height: isPhone ? undefined : 34` was worse: the style object is spread over the primitive's own, so a present key wins whatever its value is — React drops an undefined one and the pill came out 17px tall. The key is omitted on a phone now. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #51. Targets
chore/version-bump-5.2.0(PR #50), notmain.What was wrong
The phone was running the desktop layout at the same size. The figures you
read mid-session — cost, model, state, whether approvals are bypassed — were
set at 10px, below the body text; controls were 16–34px with 2–7px between
them; and nothing that ran had a viewport small enough to notice. Every
browser check mounted at 900px or wider, so this shipped through four minor
versions.
What changed
src/client/ui/touch.tsis the one place the phone's rules live: thetype steps, a 44px target floor, an 8px gap. A TypeScript module and not a
stylesheet because this app's chrome is styled with inline
React.CSSProperties— a media query would have applied to nothing. Itpublishes the phone answer through a context so a copy button five
components deep gets the same answer as the header.
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 — the more sheet already has the last two.
cards and model list take the same scale, as do the bottom bar, its more
sheet, the tab switcher and the dialogs a phone reaches.
panel-leftdoes not mean "turn index" toanybody, and a touch screen has no hover to reveal the title that said so.
Button,SelectandIconButtonstep up to the floor inside a phonecontext, so a dialog gets it without knowing it is on a phone.
One fix that is not about size
The transcript kept
min-height: 160px. A flex item cannot shrink below itsmin-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 beneath it. 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.
Verification
npm test(1359),npm run typecheck,npm run test:browser(237 checks,up from 81) all pass. The browser checks now run at three phone viewports —
portrait, keyboard-open and landscape — with each of the phone's own sheets
open in turn, and assert geometry rather than intent: target size, neighbour
spacing, type size, that the named live figures are legible, that every
control is identifiable without pressing it, and that no region is drawn over
another. The shell's own sheets are measured in an iframe, because a
Dialogportals to
document.bodyand positions against the viewport — in the mainpage it would have been 800px wide whatever the host div said.
test/browser/page.htmlnow loads the app's own stylesheets. Without themevery
var(--text-2xs)resolved to nothing and inherited 16px, so a checkmeasuring type size would have read 16px for text that ships at 10.
The desktop layout is unchanged, confirmed by screenshot against the same
fixture as well as by the existing desktop checks.
Worth knowing
detectMobile()is untouched, so which devices take themobile path is exactly as before — but a tablet in portrait was already on
that path, so it now gets the phone type scale and touch targets too. The
issue lists tablet changes as a non-goal and also asks the question openly;
this is the answer the existing detection already implied.
fixture: the issue lists the terminal's own on-screen controls as a
non-goal.
🤖 Generated with Claude Code