From 80e2c68058af66cd280a5988a7cab3936f648b14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:20:15 -0700 Subject: [PATCH] fix(mobile): one button style for the session header and tab strip; size tabs to titles (#29222) * fix(mobile): put the session tabs in the top bar on a landscape phone A sideways phone is about 390pt tall and the session header spent two rows on it. On a landscape phone the tab strip now shares the top bar with the back button, title and panel icons. Portrait and tablet layouts are unchanged. * fix(mobile): give session tabs side borders and a filled active tab In the landscape top bar the tabs sat between the title and the panel icons with nothing marking where one tab ended. Each tab now has a 1pt border on its sides, running the full height of the bar, and the active tab gets the raised fill, as the desktop tab strip draws them. The same strip is used in portrait, so both orientations match. * fix(mobile): one button style for the session header and tab strip; size tabs to titles The header had three button sizes and the new-tab and quick-commands buttons were bare 16pt glyphs in a 40x36 area with no extra touch room, so "+" was hard to hit. Every header and tab-strip button now uses the same 36pt rounded button with an 18pt icon and a 44pt touch area. Tabs size to their titles (44 to 128pt) instead of a fixed 128pt, the strip row is 44pt, terminal tabs get a terminal icon, and tabs expose the tab role and selected state to screen readers. * fix(mobile): reset tab strip measurements on view attachment * style(mobile): format tab strip ref dependencies * fix(mobile): reserve independent header button touch targets * fix(mobile): type header button test against its component * fix(mobile): keep two header rows in landscape windows too narrow for top-bar tabs A short, narrow window (a ~390x350 web window) is wider than tall, so it counted as a landscape phone and moved the tabs into a top bar that has no width left for them. Top-bar tabs now also need at least 568pt of width, the narrowest landscape phone. * fix(mobile): give the landscape top-bar tab strip a fixed left edge so scrolled tabs never read as part of the title * chore: recompute mergeability after retarget to main --- .../src/layout/host-sidebar-reveal.test.tsx | 5 ++- mobile/src/session/MobileSessionHeader.tsx | 25 ++++------- .../session/MobileSessionHeaderIconButton.tsx | 14 +++++-- mobile/src/session/MobileSessionTabStrip.tsx | 25 +++++------ mobile/src/session/QuickCommandsTabButton.tsx | 16 +++----- .../session/mobile-session-frame-styles.ts | 41 ++++++------------- 6 files changed, 52 insertions(+), 74 deletions(-) diff --git a/mobile/src/layout/host-sidebar-reveal.test.tsx b/mobile/src/layout/host-sidebar-reveal.test.tsx index 6605de9f7b1..d33a936fa29 100644 --- a/mobile/src/layout/host-sidebar-reveal.test.tsx +++ b/mobile/src/layout/host-sidebar-reveal.test.tsx @@ -1,5 +1,6 @@ import { createElement, type ReactNode } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { Pressable } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { createFakeBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' import { RpcClientProvider } from '../transport/client-context.web' @@ -124,7 +125,9 @@ describe('host sidebar reveal in the session header', () => { } function buttons(label: string) { - return tree().root.findAllByProps({ accessibilityLabel: label }) + return tree().root.findAll( + (node) => node.type === Pressable && node.props.accessibilityLabel === label + ) } it('offers no reveal in a page beside the native sidebar, which it does not draw', async () => { diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index 8aff620e24c..12fd1c88f40 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -4,7 +4,6 @@ import { ChevronLeft, Folder, GitBranch, MoreHorizontal, PanelLeftOpen } from 'l import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton' import { MobileSessionTabStrip } from './MobileSessionTabStrip' import { StatusDot } from '../components/StatusDot' -import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' import { useHostSidebarReveal } from '../layout/host-sidebar-reveal' import { useResponsiveLayout } from '../layout/responsive-layout' @@ -38,25 +37,17 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC {revealSidebar ? ( - [styles.backButton, pressed && styles.backButtonPressed]} - onPress={revealSidebar} - hitSlop={8} - accessibilityRole="button" + - - + icon={PanelLeftOpen} + onPress={revealSidebar} + /> ) : null} - [styles.backButton, pressed && styles.backButtonPressed]} - onPress={requestLeaveSession} - hitSlop={8} - accessibilityRole="button" + - - + icon={ChevronLeft} + onPress={requestLeaveSession} + /> onPress: () => void } +// Why: every header and tab-strip button shares this one look and a 44pt touch area (36pt + 4pt slop each side). export function MobileSessionHeaderIconButton({ active = false, + disabled = false, accessibilityLabel, icon: Icon, onPress @@ -25,12 +28,15 @@ export function MobileSessionHeaderIconButton({ return ( [ - styles.filesButton, - pressed && styles.filesButtonPressed, - active && styles.filesButtonActive + styles.headerButton, + pressed && styles.headerButtonPressed, + active && styles.headerButtonActive, + disabled && styles.headerButtonDisabled ]} + disabled={disabled} onPress={onPress} - hitSlop={8} + hitSlop={4} + accessibilityRole="button" accessibilityLabel={accessibilityLabel} > diff --git a/mobile/src/session/MobileSessionTabStrip.tsx b/mobile/src/session/MobileSessionTabStrip.tsx index e1f0888c7e3..ead6cc64dc6 100644 --- a/mobile/src/session/MobileSessionTabStrip.tsx +++ b/mobile/src/session/MobileSessionTabStrip.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { View, Text, ScrollView, Pressable } from 'react-native' -import { File, FileText, Globe, Plus } from 'lucide-react-native' +import { File, FileText, Globe, Plus, SquareTerminal } from 'lucide-react-native' import { triggerMediumImpact } from '../platform/haptics' import { MobileAgentIcon } from '../components/MobileAgentIcon' import { @@ -8,6 +8,7 @@ import { resolveMobileTerminalTabAgentId } from './mobile-terminal-tab-agent' import { colors } from '../theme/mobile-theme' +import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton' import { QuickCommandsTabButton } from './QuickCommandsTabButton' import { styles } from './mobile-session-styles' import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps' @@ -100,6 +101,9 @@ export function MobileSessionTabStrip({ } }} onPress={() => switchSessionTab(t)} + accessibilityRole="tab" + accessibilityLabel={getMobileSessionTabTitle(t)} + accessibilityState={{ selected: t.id === activeSessionTabId }} onLongPress={() => { triggerMediumImpact() openSessionTabActionSheetAfterKeyboardDismiss(t) @@ -120,7 +124,11 @@ export function MobileSessionTabStrip({ {t.type === 'terminal' && (() => { const agentId = resolveMobileTerminalTabAgentId(t) - return agentId ? : null + return agentId ? ( + + ) : ( + + ) })()} {/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */} - [ - styles.newTerminalButton, - pressed && styles.newTerminalButtonPressed, - (creating || creatingBrowser || creatingMarkdown || connState !== 'connected') && - styles.newTerminalButtonDisabled - ]} + { setCreateError('') setShowCreateTabDrawer(true) }} accessibilityLabel="New tab" - > - - + icon={Plus} + /> {/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */} - [ - styles.newTerminalButton, - pressed && styles.newTerminalButtonPressed, - disabled && styles.newTerminalButtonDisabled - ]} + - - + icon={SquareChevronRight} + /> ) } diff --git a/mobile/src/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts index f037a537ea9..7301c09b2eb 100644 --- a/mobile/src/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -32,32 +32,25 @@ export const mobileSessionFrameStyles = StyleSheet.create({ paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.xs - }, - backButtonPressed: { - backgroundColor: colors.bgRaised - }, - filesButton: { + // Each 36pt visual reserves 4pt per side for its 44pt touch area. + headerButton: { width: 36, height: 36, borderRadius: radii.button, alignItems: 'center', justifyContent: 'center', - marginLeft: spacing.xs + marginHorizontal: spacing.xs }, - filesButtonPressed: { + headerButtonPressed: { backgroundColor: colors.bgRaised }, // Selected state for the active docked-panel icon on wide layouts (R2). - filesButtonActive: { + headerButtonActive: { backgroundColor: colors.bgRaised }, + headerButtonDisabled: { + opacity: 0.45 + }, sessionTitleBlock: { flex: 1, minWidth: 0 @@ -106,7 +99,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({ }, tabScroll: { flex: 1, - maxHeight: 36 + maxHeight: 44 }, tabScrollInTopBar: { alignSelf: 'stretch', @@ -119,10 +112,11 @@ export const mobileSessionFrameStyles = StyleSheet.create({ tabContentInTopBar: { paddingLeft: 0 }, + // Sized to the title (capped), so short titles don't waste width. tab: { - width: 128, + minWidth: 44, maxWidth: 128, - minHeight: 36, + minHeight: 44, alignItems: 'center', justifyContent: 'center', paddingHorizontal: spacing.sm, @@ -157,20 +151,9 @@ export const mobileSessionFrameStyles = StyleSheet.create({ tabTextActive: { color: colors.textPrimary }, - newTerminalButton: { - width: 40, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderBottomWidth: 2, - borderBottomColor: 'transparent' - }, newTerminalButtonPressed: { backgroundColor: colors.bgRaised }, - newTerminalButtonDisabled: { - opacity: 0.45 - }, // Divider between the + new-terminal button and the Quick Commands launcher, // matching the tab strip's borderSubtle separators. tabActionDivider: {