diff --git a/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png b/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png new file mode 100644 index 0000000..fee5db7 Binary files /dev/null and b/.cc-web/attachments/fa4b1c8ab832-Screenshot_20260726-185147.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index a3039af..5c14614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,74 @@ ## [5.2.0] - Unreleased -Nothing yet — this section collects the work merged onto the 5.2.0 branch. +### Changed +- **A phone gets a layout built around the conversation.** It used to be the + desktop layout at the same size: the figures you read mid-session — the cost, + the model, the state, whether approvals are bypassed — were set smaller than + the body text, the controls sat close enough together that hitting the + intended one was luck, and more than half the screen went to chrome. + + Now the chat surface is one slim strip above the conversation and one row + below it. The strip says what the session is doing and what it has cost; + tapping it opens the runtime, the folder, the branch, the tokens, the context + meter and the approvals state. The row below is the message field and send, + with the attachments, the pickers, the model and the approvals readout behind + a *More*. + + The bottom bar is now a set of destinations rather than a drawer of commands. + What this app does is run agent sessions, and inside one there are four places + worth being — the conversation, what the agent did about it, the files it did + that to, and a shell in the same directory — plus the other sessions. Those + are the five, it says which one you are on, and pressing one goes there. What + used to sit in those slots mixed a place to go, a thing to make, a panel to + toggle, a file to attach and a sheet of everything left over. + + The verbs moved to a square button floating in the bottom right: search this + conversation, jump to a turn, display settings, new session, attach an image, + rename, reconnect, and the rest behind *More*. A control that changes where + you are and a control that does something to where you are no longer share a + row. + + The conversation now has about seven tenths of a phone screen, where it had + under half, and the bar gets out of the way entirely while the on-screen + keyboard is up. Everything a finger is meant to hit is at least 44px with real + space around it, nothing carrying live information is smaller than the body + text, and every control says what it is rather than being a bare glyph with a + tooltip no touch screen can show. The same treatment reaches the trace rail, + the turn index, the model list, the more sheet, the tab switcher and the + dialogs. The desktop and tablet layouts are unchanged. + +### Fixed +- **A phone no longer opens a conversation onto a panel.** Which panel is open + is a desktop preference — there the rail sits beside the transcript — but on a + phone it replaces it, so the stored setting put every conversation behind a + panel. It is session state on a phone now, and the shared preference is left + alone rather than overwritten, which would have closed the rail on the desktop + that set it open. +- **The conversation no longer spills over the live ribbon and the composer on + a short screen.** The transcript kept a fixed minimum height it could not + give up, so on a phone in landscape — or any window short enough — it grew + past the space it had and was painted over the two things below it. + +### Internal +- The automated browser checks run at phone viewports (portrait, keyboard-open + and landscape), with each of the phone's own disclosures and its menu open in + turn, and assert the geometry rather than the intent: target size, the space + between neighbours, type size, that the named live figures are legible and + reachable, that every control can be identified without pressing it, that no + region is drawn over another, and that the chrome takes no more than 170px + from the conversation — nothing else would have noticed that drifting back, + since every other rule is about the chrome being big enough. + + Three defects in the checks themselves came out of it. They ran in an 800x600 + window while mounting a 390x740 fixture, so a third of it was off-viewport + and anything that asks the viewport a question got the wrong answer. They + loaded none of the app's own stylesheets, without which every + `var(--text-2xs)` resolved to nothing and a type-size check read 16px for + text that ships at 10. And a panel animating in from `opacity: 0` was skipped + as invisible in headless Chrome, so a whole state was reported clean without + measuring anything — the menu rises without fading now, which also means it + is there under reduced motion and on a dropped first frame. ## [5.1.2] - 2026-07-26 diff --git a/src/client/shell/AppShell.tsx b/src/client/shell/AppShell.tsx index 599afc4..cc1bbd7 100644 --- a/src/client/shell/AppShell.tsx +++ b/src/client/shell/AppShell.tsx @@ -4,6 +4,7 @@ import type { AppSettings } from '../types'; import { Badge } from '../ui/relay/Badge'; import { CommandPalette, type CommandPaletteGroup } from '../ui/relay/CommandPalette'; import { Icon } from '../ui/relay/Icon'; +import { PhoneContext } from '../ui/touch'; import { IconButton } from '../ui/relay/IconButton'; import { StatusBar, type StatusBarSegment } from '../ui/relay/StatusBar'; import { TabBar, type TabItem } from '../ui/relay/TabBar'; @@ -18,7 +19,8 @@ import { SessionsDialog } from './dialogs/SessionsDialog'; import { ChatSettingsDialog } from './dialogs/ChatSettingsDialog'; import { SettingsDialog } from './dialogs/SettingsDialog'; import { TerminalOptionsDialog } from './dialogs/TerminalOptionsDialog'; -import { MobileBar, type MobileBarAction } from './MobileBar'; +import { BottomNav, type BottomNavDestination } from './BottomNav'; +import { FloatingMenu, type FloatingMenuAction } from './FloatingMenu'; import { TabSwitcherSheet } from './TabSwitcherSheet'; import { KeyStrip, KEY_STRIP_HEIGHT } from './KeyStrip'; import type { MobileKey } from '../ui/mobile'; @@ -33,7 +35,7 @@ import { TabContextMenu } from './TabContextMenu'; import { TerminalHost } from './TerminalHost'; import { ChatView } from './chat/ChatView'; import type { ChatController } from '../chat/controller'; -import type { ChatViewSettings } from '../chat/view-settings'; +import { CHAT_PANEL_ICONS, type ChatPanelId, type ChatViewSettings } from '../chat/view-settings'; import { Toasts } from './Toasts'; import { UpdateBannerView } from './UpdateBannerView'; import { shellStore, type ShellState, type ShellTab } from './store'; @@ -147,6 +149,7 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re // controller is carried as an opaque handle because its transcript mutates // per token and must not live in shell state. const chatActive = state.chat.active; + const keyboardUp = useKeyboardUp(state.isMobile); const chatController = state.chat.controller as ChatController | null; const [menu, setMenu] = React.useState<{ id: string; x: number; y: number } | null>(null); @@ -309,47 +312,119 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re { children: state.theme === 'dark' ? 'Dark' : 'Light' }, ]; - const mobileActions: MobileBarAction[] = [ - { - id: 'sessions', - label: 'Sessions', - icon: 'layout-list', - badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), - active: state.dialogs.tabs, - expands: true, - onPress: () => closeDialogs({ tabs: true }), + /** + * Where a phone can be, and where it is. + * + * Inside a conversation the app has four places worth being — the + * conversation, what the agent did, the files it did it to, and a shell in + * the same directory — plus the other sessions. Outside one there is the + * terminal and its keys. Every one of these *replaces* what fills the screen, + * which is what makes them destinations rather than toggles. + */ + /** + * On a phone, which panel is open is where you are — not a preference. + * + * The rail replaces the conversation there rather than sitting beside it, so + * a stored `panelOpen: true` would open every conversation onto a panel with + * the conversation behind it. And writing the phone's answer back would + * clobber the desktop's: the setting is shared, so one session on a phone + * would silently close the rail on the machine it was set open on. + * + * So on a phone it lives here, for the life of the tab, and the persisted + * settings never hear about it. + */ + const [phonePanel, setPhonePanel] = React.useState<{ open: boolean; tab: ChatPanelId }>(() => ({ + open: false, + tab: state.chatView.panelTab, + })); + const view: ChatViewSettings = state.isMobile + ? { ...state.chatView, panelOpen: phonePanel.open, panelTab: phonePanel.tab } + : state.chatView; + const setView = React.useCallback( + (next: ChatViewSettings): void => { + if (!shellStore.getSnapshot().isMobile) { + actions.setChatView(next); + return; + } + setPhonePanel({ open: next.panelOpen, tab: next.panelTab }); + // Everything else is still a preference and still persists; only the two + // fields that mean "where am I" are held back. + const stored = shellStore.getSnapshot().chatView; + actions.setChatView({ ...next, panelOpen: stored.panelOpen, panelTab: stored.panelTab }); }, - { id: 'new', label: 'New', icon: 'plus', onPress: actions.newTab }, - // One slot, two jobs, decided by what is on screen. - // - // The key strip sends terminal control codes, which a conversation has no - // use for; the workspace panel is meaningless without one. Giving each its - // own slot would put six items on a 390px bar with one of them always - // inert. This shows whichever the current surface can actually use. - chatActive - ? { - id: 'workspace', - label: 'Panel', - icon: 'panel-left', - active: state.chatView.panelOpen, - toggle: true, - onPress: () => - actions.setChatView({ ...state.chatView, panelOpen: !state.chatView.panelOpen }), - } - : { - // The on-screen key strip carries Escape now; this toggles it for the - // moments the terminal needs the vertical room back. + [actions], + ); + + const goChat = (): void => setView({ ...view, panelOpen: false, terminalOpen: false }); + const goPanel = (panelTab: ChatPanelId): void => + setView({ ...view, panelOpen: true, panelTab, terminalOpen: false }); + + const destinations: BottomNavDestination[] = chatActive + ? [ + { + id: 'chat', + label: 'Chat', + icon: 'message-square', + current: !view.panelOpen && !view.terminalOpen, + onGo: goChat, + }, + { + id: 'trace', + label: 'Trace', + icon: CHAT_PANEL_ICONS.trace, + current: view.panelOpen && view.panelTab === 'trace', + onGo: () => goPanel('trace'), + }, + { + id: 'files', + label: 'Files', + icon: CHAT_PANEL_ICONS.files, + current: view.panelOpen && view.panelTab !== 'trace', + onGo: () => goPanel('files'), + }, + { + id: 'terminal', + label: 'Shell', + icon: 'terminal', + current: view.terminalOpen, + onGo: () => setView({ ...view, panelOpen: false, terminalOpen: true }), + }, + { + id: 'sessions', + label: 'Sessions', + icon: 'layout-list', + badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), + onGo: () => closeDialogs({ tabs: true }), + }, + ] + : [ + { id: 'terminal', label: 'Shell', icon: 'terminal', current: true, onGo: () => {} }, + { id: 'keys', label: 'Keys', icon: 'keyboard', - active: state.keysVisible, - toggle: true, - onPress: actions.toggleKeys, + current: state.keysVisible, + onGo: actions.toggleKeys, }, - { id: 'image', label: 'Image', icon: 'image', onPress: actions.attachImage }, + { + id: 'sessions', + label: 'Sessions', + icon: 'layout-list', + badge: state.tabs.some((t) => t.unread && t.id !== state.activeId), + onGo: () => closeDialogs({ tabs: true }), + }, + ]; + + // Verbs, not places — see BottomNav. These are what the floating button is + // for, and what a destination bar has no business holding. + const sessionActions: FloatingMenuAction[] = [ + { id: 'new', label: 'New session', icon: 'plus', onPress: actions.newTab }, + { id: 'image', label: 'Attach an image', icon: 'image', onPress: actions.attachImage }, + { id: 'rename', label: 'Rename this session', icon: 'pencil', disabled: !active, onPress: () => active && closeDialogs({ rename: active.id }) }, + { id: 'reconnect', label: 'Reconnect', icon: 'rotate-cw', onPress: actions.reconnect }, { id: 'more', - label: 'More', + label: 'More…', icon: 'ellipsis', active: state.dialogs.more, expands: true, @@ -357,6 +432,8 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re }, ]; + + // No brand on a phone. It cost about a third of a 390px tab strip to say // something the user already knows, and the working directory that stood // there instead only repeated the tab title. The tabs get the room. @@ -414,6 +491,12 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re ); return ( + // The shell's phone answer, published to everything under it — including + // the dialogs, which render here rather than inside any conversation and + // would otherwise be the one part of a phone still drawn at desktop sizes. + // ChatView publishes its own for the same value; a conversation rendered + // outside this shell still has to know. See ui/touch.ts. +
closeDialogs({ chatSettings: true })} + menuActions={sessionActions} // The chat surface owns the whole viewport, so the tab strip's own // theme button is off-screen while a conversation is showing. theme={state.theme} @@ -528,7 +614,25 @@ export function AppShell({ terminalNode, actions, launcher }: AppShellProps): Re /> ) : null} - {state.isMobile ? : } + {/* The phone's only permanent chrome. A bar along the bottom edge cost + 56px plus the safe-area inset of a screen whose whole point is the + conversation; this costs the corner it covers. */} + {/* Over a conversation the menu belongs to the chat surface, which is + the only thing that knows where its composer ends — see ChatView. Out + here it covers the terminal, which has no bottom-right control of its + own to collide with. */} + {state.isMobile && !chatActive ? ( + + ) : null} + + {state.isMobile ? ( +
+
); } @@ -764,3 +869,37 @@ async function pickAndUpload(sessionId: string, directory: string): Promise { + const viewport = typeof window === 'undefined' ? null : window.visualViewport; + if (!isMobile || !viewport) { + setUp(false); + return; + } + const measure = (): void => setUp(window.innerHeight - viewport.height > 160); + measure(); + viewport.addEventListener('resize', measure); + return () => viewport.removeEventListener('resize', measure); + }, [isMobile]); + + return up; +} diff --git a/src/client/shell/BottomNav.tsx b/src/client/shell/BottomNav.tsx new file mode 100644 index 0000000..afb91de --- /dev/null +++ b/src/client/shell/BottomNav.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; + +import { Icon } from '../ui/relay/Icon'; +import { PHONE_TEXT, TOUCH_TARGET } from '../ui/touch'; + +/** + * The phone's bottom bar: where you are in the app, and how to be somewhere + * else. + * + * Destinations, not commands. The bar this replaced held whatever five things + * seemed useful — Sessions, New, Panel, Image, More — which mixed a place to + * go, a thing to make, a panel to toggle, a file to attach and a sheet of + * everything left over. Five slots of the most valuable chrome in the app, + * spent on a list with no idea in it. + * + * What the app actually does is run agent sessions, and inside one there are + * four places worth being: the conversation, what the agent did about it, the + * files it did that to, and a shell in the same directory. Plus the other + * sessions. Those are the destinations, and the bar is where you switch + * between them — so it paints which one you are on, the way a bar that says + * "you are here" is supposed to. + * + * Everything that is a *verb* — search, new session, rename, reconnect, the + * display settings — is on the floating button instead. A control that changes + * where you are and a control that does something to where you are do not + * belong in the same row. + * + * Height is published as `--mobile-bar-height` so the toast stack and the + * terminal padding can both clear it without either duplicating the number. + */ + +export interface BottomNavDestination { + id: string; + label: string; + icon: string; + /** You are here. Exactly one of these should be true. */ + current?: boolean; + onGo(): void; + /** Draws attention without a count, e.g. output arrived somewhere else. */ + badge?: boolean; + disabled?: boolean; +} + +export interface BottomNavProps { + destinations: BottomNavDestination[]; + /** + * Hidden while the on-screen keyboard is up. + * + * The keyboard takes half the screen, and the half it leaves is the half you + * are typing into — which is the one moment nobody is navigating. The bar + * comes straight back when the field is done with. + */ + hidden?: boolean; +} + +export function BottomNav({ destinations, hidden = false }: BottomNavProps): React.JSX.Element | null { + if (hidden || destinations.length === 0) return null; + + return ( + + ); +} diff --git a/src/client/shell/FloatingMenu.tsx b/src/client/shell/FloatingMenu.tsx new file mode 100644 index 0000000..c9a5a11 --- /dev/null +++ b/src/client/shell/FloatingMenu.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; + +import { Icon } from '../ui/relay/Icon'; +import { PHONE_TEXT, TOUCH_GAP, TOUCH_TARGET } from '../ui/touch'; + +/** + * The phone's one piece of permanent chrome: a square button in the bottom + * right, and everything else behind it. + * + * It replaced the bottom bar. A five-slot bar is 56px plus the safe-area inset + * of screen that the conversation never gets back, on a surface whose whole + * purpose is the conversation — and it could only ever show five things, so + * every other control had to live in a sheet reached *through* it anyway. A + * button that is 56px square and floats over the transcript costs the corner it + * covers and nothing else. + * + * Open, it is a column of labelled rows rising from the button. Rows rather + * than a grid because the labels are what make the icons legible, and a phone + * has height to give a list and no width to give a grid. + */ + +export interface FloatingMenuAction { + id: string; + label: string; + icon: string; + onPress(): void; + /** Paints the row as the current state of something, e.g. an open panel. */ + active?: boolean; + /** Opens a panel or sheet rather than navigating. */ + expands?: boolean; + /** Toggles something visible. Announced as pressed/not-pressed. */ + toggle?: boolean; + /** Draws attention without a count, e.g. background output arrived. */ + badge?: boolean; + disabled?: boolean; + /** Rows are drawn grouped by this, newest group nearest the button. */ + group?: string; +} + +export interface FloatingMenuProps { + actions: FloatingMenuAction[]; + /** Sits above whatever owns the bottom edge, e.g. the on-screen key strip. */ + bottomOffset?: number; +} + +const SIDE = 56; + +export function FloatingMenu({ actions, bottomOffset = 0 }: FloatingMenuProps): React.JSX.Element { + const [open, setOpen] = React.useState(false); + const alerting = actions.some((action) => action.badge); + + // Escape closes it, and so does the surface underneath being replaced: a menu + // still standing over a conversation that has been switched away from is a + // list of controls for something that is no longer on screen. + React.useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return; + event.stopPropagation(); + setOpen(false); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [open]); + + const groups: Array<[string, FloatingMenuAction[]]> = []; + for (const action of actions) { + const key = action.group ?? ''; + const last = groups[groups.length - 1]; + if (last && last[0] === key) last[1].push(action); + else groups.push([key, [action]]); + } + + return ( + <> + {/* Tapping anywhere else closes it. Not a `Dialog`: this must not take + focus away from the composer, because the commonest reason to open it + is to reach for something mid-sentence. */} + {open ? ( + + + + ); +} + +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 0a44f9a..0000000 --- a/src/client/shell/MobileBar.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from 'react'; - -import { Icon } from '../ui/relay/Icon'; - -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/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/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/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index 77f4afd..f9b0e8f 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -16,6 +16,8 @@ import { Button } from '../../ui/relay/Button.js'; import { Icon } from '../../ui/relay/Icon.js'; import { IconButton } from '../../ui/relay/IconButton.js'; import { showNotification } from '../../ui/notifications.js'; +import { PhoneContext } from '../../ui/touch.js'; +import { FloatingMenu } from '../FloatingMenu.js'; import { Composer } from './Composer.js'; import { MessageList, type MessageListHandle } from './MessageList.js'; import { messageText } from './MessageBubble.js'; @@ -72,6 +74,29 @@ export interface ChatViewProps { onViewChange?: (next: ChatViewSettings) => void; theme?: 'dark' | 'light'; onToggleTheme?: () => void; + /** + * The shell's own menu entries — sessions, new, more. + * + * Passed down rather than this surface's being passed up, because the menu + * has to be positioned by whoever knows where the composer is. Anchored to + * the shell it sat exactly on top of the send button; anchored to the + * transcript it floats over the conversation and clears everything. + */ + menuActions?: SurfaceAction[]; +} + +/** One control on the phone's floating menu. */ +export interface SurfaceAction { + id: string; + label: string; + icon: string; + onPress(): void; + active?: boolean; + expands?: boolean; + toggle?: boolean; + badge?: boolean; + disabled?: boolean; + group?: string; } /** @@ -102,6 +127,7 @@ export function ChatView({ onViewChange, theme, onToggleTheme, + menuActions, }: ChatViewProps) { const transcript = controller.transcript; @@ -204,11 +230,6 @@ export function ChatView({ // leave the timeline's effect with nothing to react to. const [focus, setFocus] = React.useState<{ id?: string; nonce: number }>({ nonce: 0 }); const [indexSheet, setIndexSheet] = React.useState(false); - // The rail's persisted flag is a desktop preference. On a phone the rail is - // the whole screen, so opening it is a gesture that happens *now* — a stored - // `true` would mean every conversation opened onto a panel with the - // conversation hidden behind it. - const [mobileRail, setMobileRail] = React.useState(false); const [planSheet, setPlanSheet] = React.useState(false); // A draft seed, not a controlled value: making the composer controlled would // re-render this component — and re-derive the turns and the whole activity @@ -234,22 +255,24 @@ export function ChatView({ // ------------------------------------------------------------------ zones const panelsAvailable = enabledPanels(view).length > 0; - const railOpen = panelsAvailable && (isMobile ? mobileRail : view.panelOpen); + // One answer for both, so the phone's bottom bar can drive it. It used to be + // separate local state on a phone, which meant the rail was open according to + // this component and shut according to everything outside it — including the + // bar that now has to paint which destination you are on. + const railOpen = panelsAvailable && view.panelOpen; // On a phone the rail replaces the conversation rather than sitting beside // it: 344px of panel next to a 390px viewport leaves neither usable. const railTakesOver = isMobile && railOpen; const toggleRail = React.useCallback(() => { - if (isMobile) setMobileRail((open) => !open); - else setView({ panelOpen: !view.panelOpen }); - }, [isMobile, setView, view.panelOpen]); + setView({ panelOpen: !view.panelOpen }); + }, [setView, view.panelOpen]); const openRail = React.useCallback( (panelTab: ChatPanelId) => { - if (isMobile) setMobileRail(true); if (!view.panelOpen || view.panelTab !== panelTab) setView({ panelOpen: true, panelTab }); }, - [isMobile, setView, view.panelOpen, view.panelTab], + [setView, view.panelOpen, view.panelTab], ); const narrow = width > 0 && width < HIDE_INDEX_AT; @@ -493,7 +516,36 @@ export function ChatView({ /> ); + const openIndex = React.useCallback(() => { + // One control, two jobs, decided by whether the index has room to be a rail + // at all. Below 1024px — and on every phone — there is no rail to toggle, + // so the same control opens the sheet instead of writing a setting whose + // effect nothing on screen would show. + if (indexVisible || !(isMobile || narrow)) setView({ indexOpen: !view.indexOpen }); + else setIndexSheet(true); + }, [indexVisible, isMobile, narrow, setView, view.indexOpen]); + + // This surface's own controls, then the shell's. The ones somebody opened the + // menu mid-conversation for come first, nearest the button they pressed. + const phoneMenu: SurfaceAction[] = React.useMemo( + () => [ + // Things you do to this conversation. Where you can *go* from it — the + // trace, the files, the shell, the other sessions — is the bottom bar's + // job, and repeating those here would be two answers to one question. + { id: 'chat-search', label: 'Search this conversation', icon: 'search', expands: true, group: 'surface', onPress: () => setSearchOpen(true) }, + { id: 'chat-turns', label: 'Jump to a turn', icon: 'panel-left', active: indexVisible || indexSheet, expands: true, group: 'surface', onPress: openIndex }, + { id: 'chat-display', label: 'Display settings', icon: 'settings', expands: true, group: 'surface', onPress: () => onOpenSettings?.() }, + ...(menuActions ?? []).map((action) => ({ ...action, group: 'session' })), + ], + [indexVisible, indexSheet, openIndex, onOpenSettings, menuActions], + ); + return ( + // The phone answer, published once for the whole surface. Everything below + // — down to a copy button inside a tool call — reads it from here rather + // than from a prop threaded through five components, any one of which could + // drop it and leave that corner at desktop sizes. See ui/touch.ts. +
} aria-label={`${runtimeLabel} chat`} @@ -537,11 +589,7 @@ export function ChatView({ // rail at all. Below 1024px — and on every phone — there is no rail to // toggle, so the same button opens the sheet instead of writing a // setting whose effect nothing on screen would show. - onToggleIndex={ - indexVisible || !(isMobile || narrow) - ? () => setView({ indexOpen: !view.indexOpen }) - : () => setIndexSheet(true) - } + onToggleIndex={openIndex} onOpenSearch={() => setSearchOpen(true)} onOpenSettings={() => onOpenSettings?.()} onToggleTheme={onToggleTheme} @@ -591,6 +639,11 @@ export function ChatView({ position: 'relative', }} > + {/* Over the conversation, clear of everything else. Anchored to the + transcript rather than to the shell: at the shell's bottom right + it sat exactly on the send button. */} + {isMobile ? : null} + {searchOpen ? ( setView({ panelTab })} - onClose={() => setMobileRail(false)} + onClose={() => setView({ panelOpen: false })} isMobile /> ) : null} @@ -767,7 +820,7 @@ export function ChatView({ // want to type the follow-up — while the agent waits on you — was // the one moment you could not. It queues instead. disabled={exited || Boolean(unavailable)} - placeholder={placeholderFor(chatState, runtimeLabel)} + placeholder={placeholderFor(chatState, runtimeLabel, isMobile)} queued={transcript.queuedTurns} onCancelQueued={cancelQueued} onFindFiles={findProjectFiles} @@ -845,6 +898,7 @@ export function ChatView({
+
); } @@ -854,15 +908,20 @@ function viewportHeight(): number { return typeof window === 'undefined' ? 900 : window.innerHeight; } -function placeholderFor(state: ChatState, runtimeLabel: string): string { +function placeholderFor(state: ChatState, runtimeLabel: string, isPhone = false): string { if (state === 'exited') return 'This session has ended'; // No longer 'answer this before you can type': the composer stays live and // queues, so the sentence has to describe what happens rather than forbid it. - if (state === 'awaiting_permission') return 'Answer above — or type the next message'; + if (state === 'awaiting_permission') { + return isPhone ? 'Answer above, or type' : 'Answer above — or type the next message'; + } if (state === 'thinking' || state === 'running') { - return `Queue a follow-up while ${runtimeLabel} works…`; + // Short enough for the one line a phone's field is. The long form wrapped + // to two, which grew the composer by 26px for a sentence saying what the + // ribbon directly above it already says. + return isPhone ? 'Queue a follow-up…' : `Queue a follow-up while ${runtimeLabel} works…`; } - return `Message ${runtimeLabel}…`; + return isPhone ? 'Message…' : `Message ${runtimeLabel}…`; } /** diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index 30f4c73..d736b89 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -12,6 +12,7 @@ import { mentionAtCaret } from '../../../shared/file-match.js'; import { classifyPaste, MAX_IMAGES_PER_PASTE, PasteCandidate } from '../../../shared/paste-classify.js'; import { MAX_IMAGE_BYTES } from '../../terminal/paste.js'; import { detectMobile } from '../../ui/mobile.js'; +import { PHONE_TEXT, PhoneContext, TOUCH_GAP, TOUCH_TARGET, usePhone } from '../../ui/touch.js'; import { showNotification } from '../../ui/notifications.js'; import { Icon } from '../../ui/relay/Icon.js'; import { IconButton } from '../../ui/relay/IconButton.js'; @@ -185,7 +186,22 @@ export function Composer({ /** Where to put the caret after a completion rewrites the draft. */ const pendingCaret = React.useRef(null); - const [isMobile, setIsMobile] = React.useState(safeDetectMobile); + /** + * The surface's answer wins over this component's own. + * + * The composer used to decide "am I on a phone?" by calling `detectMobile()` + * itself, which made it the app's second source for that answer and put it + * out of reach of anything that renders the surface deliberately — a check at + * a phone viewport in headless Chrome has no touch points, so the real + * composer could never be examined at the size it actually ships at. + * + * The local detection stays as the fallback: `Composer` is also rendered on + * its own, outside any `PhoneContext`, and Enter's two jobs (send versus + * newline) still have to be decided correctly there. + */ + const surfaceIsPhone = usePhone(); + const [detectedMobile, setIsMobile] = React.useState(safeDetectMobile); + const isMobile = surfaceIsPhone || detectedMobile; const [uncontrolledText, setUncontrolledText] = React.useState(''); const [entries, setEntries] = React.useState([]); const [dragActive, setDragActive] = React.useState(false); @@ -204,6 +220,15 @@ export function Composer({ * draft — it opens the list and lets the completion do the rewriting. */ const [commandsForced, setCommandsForced] = React.useState(false); + /** + * Whether the phone's secondary controls are showing. + * + * Attach, the two pickers, the model and the approvals readout are five + * controls that a phone has no room for beside the field and no reason to + * show while you are typing — the two that matter mid-sentence are send and + * stop. Shut by default, and the room goes to the conversation. + */ + const [toolsOpen, setToolsOpen] = React.useState(false); // A tablet rotated into portrait, or a touch laptop window resized, changes // which of Enter's two jobs (send vs newline) is correct — see mobile.ts. @@ -610,8 +635,21 @@ export function Composer({ }; const sendLabel = busy ? 'Queue this message' : 'Send message'; + /** + * The phone's resting shape: the field and its buttons on one line. + * + * Only while the extra controls are shut. Open, they are five more chips that + * have nowhere to go on a shared line, so the column comes back. + */ + const phoneCollapsed = isMobile && !toolsOpen; return ( + // Re-published rather than merely consumed: `isMobile` here is the surface's + // answer *or* this component's own detection, and every control below — + // the chips, the model picker, send and stop — has to size itself from the + // same one. Composer mounted on its own on a real phone would otherwise + // send with Enter like a phone and draw its buttons like a desktop. +
@@ -655,6 +693,19 @@ export function Composer({
) : null} + {/* One line, not two, while the extra controls are shut. + `display: contents` everywhere else, so the desktop keeps the column + it has: the field over its own row of actions, which is what stops the + buttons floating in the middle of a twelve-line draft. On a phone + collapsed there is no twelve-line draft to float in and the second row + was 52px the conversation could have had. */} +