From 3579d92112e4a0a0cacfdc67df00100b309f4152 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sat, 14 Nov 2026 23:26:42 +0100 Subject: [PATCH 1/2] Float the background-work chip and open it as a card The background-work chip sat in an opaque band above the composer because the transcript spacer reserved the whole dock, chip included. Only the rest of the dock now reserves space, measured in one piece, and the inverted list pads its newest end by the chip's height. The transcript scrolls under the chip, and touches beside it reach the rows behind. The chip spins while a subagent runs, shows a still terminal glyph when only shells run, and keeps the warm dot when something needs you. Tapping it opens a card over the chip instead of a sheet: the screen dims, tapping outside closes it, and a shell's page opens inside the card. The card is measured from the chip again on rotation, resize, keyboard changes and once shown, and stays within the safe area. Choosing an agent, model or variant no longer folds the composer. A hold keeps it expanded until the prompt is tapped and blurs, or the prompt is sent, so focus passing through the prompt as a sheet closes does not fold it. Model changes read inline as "Model changed to" the model and variant. --- apps/mobile/src/components/modal-sheet.tsx | 2 +- .../src/components/popover-card.test.tsx | 122 ++++++++++++ apps/mobile/src/components/popover-card.tsx | 169 +++++++++++++++++ .../src/components/working-indicator.tsx | 4 +- .../screens/session-background-tasks.test.tsx | 38 +++- .../src/screens/session-background-tasks.tsx | 89 ++++++--- .../src/screens/session-composer.test.tsx | 47 +++++ apps/mobile/src/screens/session-composer.tsx | 37 +++- .../src/screens/session-transcript.test.tsx | 21 ++- .../mobile/src/screens/session-transcript.tsx | 21 ++- .../workspace-screen.integration.test.tsx | 30 ++- apps/mobile/src/screens/workspace-screen.tsx | 176 ++++++++++++------ docs/COMPATIBILITY.md | 28 +++ 13 files changed, 679 insertions(+), 105 deletions(-) create mode 100644 apps/mobile/src/components/popover-card.test.tsx create mode 100644 apps/mobile/src/components/popover-card.tsx diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx index 2e94636..f10eab0 100644 --- a/apps/mobile/src/components/modal-sheet.tsx +++ b/apps/mobile/src/components/modal-sheet.tsx @@ -111,7 +111,7 @@ export function focusAccessibility(target: Text | View | null | undefined) { if (handle) AccessibilityInfo.setAccessibilityFocus(handle); } -function useReturnFocusAfterClose( +export function useReturnFocusAfterClose( visible: boolean, returnFocusRef: RefObject | undefined, ) { diff --git a/apps/mobile/src/components/popover-card.test.tsx b/apps/mobile/src/components/popover-card.test.tsx new file mode 100644 index 0000000..e914c65 --- /dev/null +++ b/apps/mobile/src/components/popover-card.test.tsx @@ -0,0 +1,122 @@ +import { expect, jest, test } from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import type { RefObject } from "react"; +import { Dimensions, Keyboard, Modal, StyleSheet, Text, type View } from "react-native"; + +import { space } from "../theme"; +import { PopoverCard } from "./popover-card"; + +const mockInsets = { bottom: 0, left: 0, right: 0, top: 0 }; +jest.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => mockInsets })); + +function anchorAt(position: { y: number }, height = 36) { + return { + current: { + measureInWindow: (callback: (x: number, y: number, w: number, h: number) => void) => + callback(16, position.y, 120, height), + }, + } as unknown as RefObject; +} + +function renderCard( + anchorRef: RefObject, + props: Partial[0]> = {}, +) { + const onClose = jest.fn(); + render( + + Row + , + ); + return { onClose }; +} + +const card = () => screen.getByTestId("popover-card"); + +test("pins the card's bottom edge to the bottom of the control it opens from", () => { + const windowHeight = Dimensions.get("window").height; + const { onClose } = renderCard(anchorAt({ y: windowHeight - 200 })); + expect(card()).toHaveStyle({ bottom: 164 }); + fireEvent.press(screen.getByTestId("popover-backdrop", { includeHiddenElements: true })); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +test("never grows taller than the space above it, and fills it for a self-sizing page", () => { + const windowHeight = Dimensions.get("window").height; + // An anchor near the top leaves little room: the card shrinks instead of a fixed minimum. + renderCard(anchorAt({ y: 60 }), { fill: true, scrollable: false }); + const style = StyleSheet.flatten(card().props.style); + expect(style.bottom).toBe(windowHeight - 96); + expect(style.maxHeight).toBeLessThan(160); + expect(style.height).toBe(style.maxHeight); +}); + +test("measures the control again when the window changes size or the keyboard moves", () => { + const windowHeight = Dimensions.get("window").height; + const position = { y: windowHeight - 200 }; + const listeners = new Map void>(); + const addListener = jest.spyOn(Keyboard, "addListener").mockImplementation((( + event: string, + listener: () => void, + ) => { + listeners.set(event, listener); + return { remove: jest.fn() }; + }) as never); + const window = Dimensions.get("window"); + try { + renderCard(anchorAt(position)); + expect(card()).toHaveStyle({ bottom: 164 }); + position.y = windowHeight - 300; + act(() => { + Dimensions.set({ window: { ...window, width: window.width + 1 } }); + }); + expect(card()).toHaveStyle({ bottom: 264 }); + position.y = windowHeight - 250; + act(() => listeners.get("keyboardDidHide")?.()); + expect(card()).toHaveStyle({ bottom: 214 }); + // The card can fold the composer under its anchor as it opens; showing measures again. + position.y = windowHeight - 220; + act(() => screen.UNSAFE_getByType(Modal).props.onShow()); + expect(card()).toHaveStyle({ bottom: 184 }); + } finally { + act(() => { + Dimensions.set({ window }); + }); + addListener.mockRestore(); + } +}); + +test("the escape gesture uses the inner page's back before closing", () => { + const onRequestClose = jest.fn(); + const { onClose } = renderCard(anchorAt({ y: 600 }), { onRequestClose }); + fireEvent(card(), "accessibilityEscape"); + expect(onRequestClose).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); +}); + +test("stays clear of a notch on one side instead of centering into it", () => { + const window = Dimensions.get("window"); + mockInsets.right = 44; + try { + act(() => { + Dimensions.set({ window: { ...window, width: 700 } }); + }); + renderCard(anchorAt({ y: 300 })); + const style = StyleSheet.flatten(card().props.style); + const rightMargin = 700 - Number(style.left) - Number(style.width); + expect(rightMargin).toBeGreaterThanOrEqual(44 + space.sm); + expect(Number(style.left)).toBeGreaterThanOrEqual(space.md); + } finally { + mockInsets.right = 0; + act(() => { + Dimensions.set({ window }); + }); + } +}); diff --git a/apps/mobile/src/components/popover-card.tsx b/apps/mobile/src/components/popover-card.tsx new file mode 100644 index 0000000..bd5efdf --- /dev/null +++ b/apps/mobile/src/components/popover-card.tsx @@ -0,0 +1,169 @@ +import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; +import { + Keyboard, + Modal, + Pressable, + ScrollView, + StyleSheet, + type Text, + useWindowDimensions, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { floatingShadow, palette, radius, space } from "../theme"; +import { focusAccessibility, useReturnFocusAfterClose } from "./modal-sheet"; + +// Matches the sheet's readable width on tablets. +const maxCardWidth = 640; + +// A card that opens from a control near the bottom of the screen and covers it, with the +// screen dimmed behind. Tapping outside, Android back, or the iOS escape gesture closes it. +// Lighter than a sheet for a short list the reader glances at and leaves. +export function PopoverCard({ + anchorRef, + children, + fill = false, + initialFocusRef, + label, + onClose, + onRequestClose, + returnFocusRef, + scrollable = true, + visible, +}: { + // The control the card opens from; its bottom edge pins the card's bottom edge. + anchorRef: RefObject; + children: ReactNode; + // Use the full available height, for content that sizes itself to its container. + fill?: boolean; + initialFocusRef?: RefObject | undefined; + label: string; + onClose: () => void; + // Android back and the iOS escape gesture. Defaults to onClose; an inner page passes Back. + onRequestClose?: (() => void) | undefined; + returnFocusRef: RefObject; + scrollable?: boolean; + visible: boolean; +}) { + const { height, width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { anchorBottom, measure } = useAnchorBottom(anchorRef, visible, width, height); + const cardRef = useRef(null); + // iOS returns focus in onDismiss; Android has no dismiss event, so it returns on close. + useReturnFocusAfterClose(visible, returnFocusRef); + + const bottomEdge = anchorBottom ?? height - insets.bottom - space.xl; + const bottom = Math.max(height - bottomEdge, insets.bottom + space.sm); + // Never taller than the space above its bottom edge, so it can't run under the status bar. + const maxHeight = Math.max(0, height - bottom - insets.top - space.md); + const side = space.md; + const cardWidth = Math.min( + width - Math.max(side, insets.left + space.sm) - Math.max(side, insets.right + space.sm), + maxCardWidth, + ); + const leftEdge = Math.max(side, insets.left + space.sm); + const rightEdge = width - Math.max(side, insets.right + space.sm); + // Centered when there's room, and always clear of a notch on either side. + const left = Math.min(Math.max(leftEdge, (width - cardWidth) / 2), rightEdge - cardWidth); + + return ( + focusAccessibility(returnFocusRef.current)} + onRequestClose={onRequestClose ?? onClose} + onShow={() => { + // Opening can fold the composer under the anchor (its keyboard-less focus ends), which + // moves the anchor without any keyboard event; measure again once the card is up. + measure(); + focusAccessibility(initialFocusRef?.current ?? cardRef.current); + }} + statusBarTranslucent + supportedOrientations={["portrait", "landscape"]} + transparent + visible={visible} + > + + + {scrollable ? ( + + {children} + + ) : ( + {children} + )} + + + ); +} + +// Where the anchor's bottom edge sits in the window while the card is open. Measured again +// when the window changes size or the keyboard moves, so the card keeps covering the anchor. +function useAnchorBottom( + anchorRef: RefObject, + visible: boolean, + width: number, + height: number, +) { + const [anchorBottom, setAnchorBottom] = useState(); + const measure = () => + anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => { + if (anchorHeight > 0) setAnchorBottom(y + anchorHeight); + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: width and height re-run the measure on rotation and resize. + useEffect(() => { + if (!visible) { + setAnchorBottom(undefined); + return; + } + const remeasure = () => + anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => { + if (anchorHeight > 0) setAnchorBottom(y + anchorHeight); + }); + remeasure(); + const shown = Keyboard.addListener("keyboardDidShow", remeasure); + const hidden = Keyboard.addListener("keyboardDidHide", remeasure); + return () => { + shown.remove(); + hidden.remove(); + }; + }, [anchorRef, visible, width, height]); + return { anchorBottom, measure }; +} + +const styles = StyleSheet.create({ + backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.45)" }, + card: { + backgroundColor: palette.card, + borderColor: palette.border, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + ...floatingShadow, + overflow: "hidden", + position: "absolute", + }, + content: { gap: space.md, paddingVertical: space.md }, + fixed: { flex: 1 }, +}); diff --git a/apps/mobile/src/components/working-indicator.tsx b/apps/mobile/src/components/working-indicator.tsx index 3eb6ebb..e1931f9 100644 --- a/apps/mobile/src/components/working-indicator.tsx +++ b/apps/mobile/src/components/working-indicator.tsx @@ -18,8 +18,8 @@ const dots = [0, 1, 2, 3, 4, 5].map((bit) => ({ // The one working animation, shared by the session list and the session screen. The inbox // passes its blue working color so the spinner matches the row's status text. Only the Working -// line and the background-work chip animate; other running glyphs pass animated={false} and keep -// the still first frame, so one screen never has a field of spinners ticking out of step. +// line and the background-work chip (while a subagent runs) animate; other running glyphs pass +// animated={false} and keep the still first frame, so a screen never fills with spinners. export function WorkingIndicator({ animated = true, color = palette.activity, diff --git a/apps/mobile/src/screens/session-background-tasks.test.tsx b/apps/mobile/src/screens/session-background-tasks.test.tsx index 72cb439..a0bf41f 100644 --- a/apps/mobile/src/screens/session-background-tasks.test.tsx +++ b/apps/mobile/src/screens/session-background-tasks.test.tsx @@ -14,10 +14,18 @@ import { render as renderWithoutQueries, screen, waitFor, + within, } from "@testing-library/react-native"; import type { ReactElement, ReactNode } from "react"; -import { AccessibilityInfo, Alert, type AlertButton, Modal, Platform } from "react-native"; - +import { + AccessibilityInfo, + Alert, + type AlertButton, + Keyboard, + Modal, + Platform, +} from "react-native"; +import { WorkingIndicator } from "../components/working-indicator"; import { palette } from "../theme"; import { SessionBackgroundTasks } from "./session-background-tasks"; import { SessionShellScope } from "./session-shell-output"; @@ -151,8 +159,23 @@ test("the chip opens the monitor, whose rows open the child without repeating it const chip = screen.getByRole("button", { name: "1 subagent, running in this session" }); expect(chip).toHaveStyle({ minHeight: 36 }); expect(chip.props.hitSlop).toBe(6); + // A running subagent is an agent at work, so the chip spins rather than showing a glyph. + expect(within(chip).UNSAFE_getByType(WorkingIndicator)).toBeTruthy(); + expect(within(chip).queryByTestId("background-shells-live")).toBeNull(); + expect(within(chip).queryByTestId("subagent-monitor-waiting")).toBeNull(); + const dismissKeyboard = jest.spyOn(Keyboard, "dismiss").mockImplementation(() => undefined); fireEvent.press(chip); - expect(screen.getByRole("header", { name: "Background work" })).toBeOnTheScreen(); + // The card pins itself to the chip, so the keyboard closes before it measures. + expect(dismissKeyboard).toHaveBeenCalledTimes(1); + dismissKeyboard.mockRestore(); + // A card over the chip, not a sheet; tapping outside it closes it. + expect(screen.getByLabelText("Background work")).toBeOnTheScreen(); + // VoiceOver keeps to the card and closes it with the escape gesture; TalkBack reaches the + // backdrop by its label. + expect( + screen.getByTestId("popover-backdrop", { includeHiddenElements: true }).props + .accessibilityLabel, + ).toBe("Close Background work"); expect(screen.getByRole("header", { name: "Subagents" })).toBeOnTheScreen(); expect(screen.queryByRole("header", { name: "Shells" })).toBeNull(); fireEvent.press(screen.getByRole("button", { name: "Delegated task, Running" })); @@ -600,7 +623,7 @@ async function openShellPage(command = "pnpm dev") { expect(await screen.findByRole("button", { name: `Stop ${command}` })).toBeOnTheScreen(); } -test("Android back on the shell page returns to the list instead of closing the sheet", async () => { +test("Android back on the shell page returns to the list instead of closing the card", async () => { const platform = jest.replaceProperty(Platform, "OS", "android"); try { shellStates = { sh_dev: {} }; @@ -620,7 +643,7 @@ test("Android back on the shell page returns to the list instead of closing the } }); -test("an iOS swipe on the shell page closes the sheet, so the chip can open it again", async () => { +test("tapping outside the card closes it from the shell page, and the chip opens it again", async () => { shellStates = { sh_dev: {} }; renderWithShells( , ); await openShellPage(); - act(() => screen.UNSAFE_getByType(Modal).props.onRequestClose()); + fireEvent.press(screen.getByTestId("popover-backdrop", { includeHiddenElements: true })); expect(screen.UNSAFE_getByType(Modal).props.visible).toBe(false); fireEvent.press(screen.getByRole("button", { name: "1 shell, running in this session" })); expect(screen.UNSAFE_getByType(Modal).props.visible).toBe(true); @@ -845,6 +868,8 @@ test("a failed status refresh makes the chip say its status is unavailable, with , ); await findChip("1 shell, running in this session"); + // Shells alone are processes, so the chip shows a still terminal glyph, not the spinner. + expect(screen.getByTestId("background-shells-live")).toBeOnTheScreen(); getShell.mockRejectedValue(new Error("UNREACHABLE")); await act(async () => { await queryClient.refetchQueries({ type: "active" }); @@ -854,6 +879,7 @@ test("a failed status refresh makes the chip say its status is unavailable, with ).toBeOnTheScreen(); // Sighted users see the uncertainty too: the count is dimmed. expect(screen.getByText("1 shell")).toHaveStyle({ color: palette.dim }); + expect(screen.getByTestId("background-shells-last-known")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "1 shell, status unavailable" })); expect(screen.getByRole("button", { name: "pnpm dev, Last known: Running" })).toBeOnTheScreen(); }); diff --git a/apps/mobile/src/screens/session-background-tasks.tsx b/apps/mobile/src/screens/session-background-tasks.tsx index 2aefd3c..82a82e6 100644 --- a/apps/mobile/src/screens/session-background-tasks.tsx +++ b/apps/mobile/src/screens/session-background-tasks.tsx @@ -4,6 +4,7 @@ import { type RefObject, useEffect, useRef, useState } from "react"; import { AccessibilityInfo, Alert, + Keyboard, Platform, Pressable, StyleSheet, @@ -13,7 +14,8 @@ import { } from "react-native"; import { Button } from "../components/button"; -import { focusAccessibility, ModalSheet } from "../components/modal-sheet"; +import { focusAccessibility } from "../components/modal-sheet"; +import { PopoverCard } from "../components/popover-card"; import { SubagentElapsed, SubagentMetaLine, @@ -82,17 +84,17 @@ export function SessionBackgroundTasks({ const stopShell = useStopShell(); useBackgroundSubagentAnnouncements(subagents, stale); const running = live.length + shells.length; - // Shells whose status refresh failed are only last known: the chip says so and stays still - // when nothing it counts is confirmed live. + // Shells whose status refresh failed are only last known: the chip says so, and its shell + // icon dims when none is confirmed live. const uncertain = shells.some((shell) => shell.lastKnown); - const confirmedLive = live.length + shells.filter((shell) => !shell.lastKnown).length > 0; + const shellsLive = shells.some((shell) => !shell.lastKnown); if ((running === 0 || stale) && !visible) return null; const count = backgroundWorkSummary(live.length, shells.length); const label = waiting > 0 ? `${count} · ${waiting} ${waiting === 1 ? "needs" : "need"} you` : count; const chipVisible = running > 0 && !stale; return ( - + {chipVisible ? ( setVisible(true)} + onPress={() => { + // The card pins itself to the chip; a keyboard closing after it opens would move it. + Keyboard.dismiss(); + setVisible(true); + }} style={({ pressed }) => [ styles.chip, waiting > 0 && styles.chipWaiting, @@ -109,10 +115,20 @@ export function SessionBackgroundTasks({ ]} testID="subagent-monitor-chip" > + {/* One icon for the whole chip: a running subagent is an agent at work and spins; + shells alone are processes, so they get a still terminal glyph. */} {waiting > 0 ? ( + ) : live.length > 0 ? ( + ) : ( - + )} ; onClose: () => void; onOpenChild: (sessionID: string) => void; onStopChild?: ((sessionID: string) => Promise) | undefined; onStopShell?: ((shellID: string) => Promise) | undefined; - returnFocusRef: RefObject; shells: ShellCandidate[]; stale: boolean; subagents: SessionSubagent[]; @@ -193,7 +210,14 @@ function SubagentMonitorSheet({ const shellRows = useRef(new Map()); const shellsHeader = useRef(null); // The list's first heading, or its empty state: present whatever is left to show. - const listStart = useRef(null); + const listStart = useRef(null); + const staleNotice = useRef(null); + // Opening reads from the top: the stale notice, else the first section, else Shells. + const firstFocus = useRef({ + get current(): Text | View | null { + return staleNotice.current ?? listStart.current ?? shellsHeader.current; + }, + }).current; const back = () => { const shellID = openShell?.shellID; setOpenShell(undefined); @@ -221,19 +245,33 @@ function SubagentMonitorSheet({ onClose(); }; return ( - + {stale ? ( + + Status unavailable · showing last known + + ) : null} {openShell ? ( - + + + ) : ( <> {sections.map((section) => @@ -319,7 +357,7 @@ function SubagentMonitorSheet({ ) : null} )} - + ); } @@ -523,13 +561,8 @@ const styles = StyleSheet.create({ copy: { flex: 1, gap: 2, minWidth: 0 }, empty: { ...typography.subheadline, color: palette.dim }, error: { ...typography.footnote, color: palette.danger }, - group: { - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: radius.md, - borderWidth: StyleSheet.hairlineWidth, - overflow: "hidden", - }, + // The card is the surface, so groups only stack their rows. + group: { overflow: "hidden" }, label: { ...typography.control, color: palette.ink, flexShrink: 1 }, labelUncertain: { color: palette.dim }, meta: { ...typography.footnote, color: palette.dim }, @@ -549,6 +582,8 @@ const styles = StyleSheet.create({ }, section: { gap: space.xs }, sectionTitle: { ...typography.sectionLabel, color: palette.dim, paddingHorizontal: space.md }, + shellPage: { flex: 1, paddingHorizontal: space.md }, + staleNotice: { ...typography.footnote, color: palette.dim, paddingHorizontal: space.md }, shellGlyph: { alignItems: "center", height: 20, justifyContent: "center", width: 16 }, separator: { borderBottomColor: palette.border, borderBottomWidth: StyleSheet.hairlineWidth }, title: { ...typography.body, color: palette.ink }, diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx index 2fe75de..5a6960e 100644 --- a/apps/mobile/src/screens/session-composer.test.tsx +++ b/apps/mobile/src/screens/session-composer.test.tsx @@ -418,6 +418,53 @@ test("selects a variant separately and can return to the model default", () => { expect(screen.getByRole("button", { name: "Variant: Default" })).toBeOnTheScreen(); }); +test("stays expanded after choosing an agent or variant until the prompt is tapped and blurs", () => { + render(); + const input = screen.getByLabelText("Prompt"); + fireEvent(input, "focus"); + fireEvent.press(screen.getByRole("button", { name: "Agent: Choose agent" })); + // Opening a picker dismisses the keyboard, which blurs the prompt. + fireEvent(input, "blur"); + fireEvent.press(screen.getByRole("button", { name: "Build" })); + expect(screen.getByRole("button", { name: "Agent: Build" })).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole("button", { name: "Model: Choose model" })); + fireEvent.press(screen.getByRole("button", { name: /Model One/ })); + fireEvent.press(screen.getByRole("button", { name: "Variant: Default" })); + fireEvent.press(screen.getByRole("button", { name: "deep" })); + expect(screen.getByRole("button", { name: "Variant: deep" })).toBeOnTheScreen(); + expect(screen.getByLabelText("Prompt")).toHaveStyle({ minHeight: 58, maxHeight: 120 }); + + // A closing sheet can move focus through the prompt on its own; that keeps the hold. + fireEvent(screen.getByLabelText("Prompt"), "focus"); + fireEvent(screen.getByLabelText("Prompt"), "blur"); + expect(screen.getByRole("button", { name: "Agent: Build" })).toBeOnTheScreen(); + + fireEvent(screen.getByLabelText("Prompt"), "pressIn"); + fireEvent(screen.getByLabelText("Prompt"), "focus"); + fireEvent(screen.getByLabelText("Prompt"), "blur"); + expect(screen.queryByRole("button", { name: /^Agent:/ })).toBeNull(); + expect(screen.getByLabelText("Prompt")).toHaveStyle({ height: 42 }); +}); + +test("closing a picker without a choice keeps the composer expanded, and sending folds it", () => { + const dismissKeyboard = jest.spyOn(Keyboard, "dismiss").mockImplementation(() => undefined); + const onSubmit = jest.fn(); + render(); + const input = screen.getByLabelText("Prompt"); + fireEvent.changeText(input, "Ship it"); + fireEvent(input, "focus"); + fireEvent.press(screen.getByRole("button", { name: "Agent: Choose agent" })); + fireEvent(input, "blur"); + fireEvent.press(screen.getByRole("button", { name: "Close Choose agent" })); + expect(screen.getByRole("button", { name: "Agent: Choose agent" })).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole("button", { name: "Send" })); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: /^Agent:/ })).toBeNull(); + dismissKeyboard.mockRestore(); +}); + test("opening and closing a footer picker keeps the current draft", () => { render(); fireEvent(screen.getByLabelText("Prompt"), "focus"); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 1f62fcc..f350838 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -158,11 +158,16 @@ export function SessionComposer({ const [agentSearch, setAgentSearch] = useState(""); const [focused, setFocused] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); + // Opening a picker dismisses the keyboard and blurs the input. Hold the composer open until + // the prompt is tapped again or sent, so a choice doesn't fold the toolbar away and the + // picker's return focus still has its selector to land on. + const [pickerHold, setPickerHold] = useState(false); + const promptTapped = useRef(false); const [justSent, markSent] = useJustSent(); const [selection, setSelection] = useState({ end: draft.length, start: draft.length }); const deferredAgentSearch = useDeferredValue(agentSearch.trim().toLocaleLowerCase()); const selectedModel = findModel(models, model); - const expanded = largeText || focused || Boolean(optionsPage) || modelPickerOpen; + const expanded = largeText || focused || pickerHold || Boolean(optionsPage) || modelPickerOpen; const mentionTrigger = findMentionTrigger(draft, selection); const submitIntent = resolveComposerSubmitIntent(draft, commands, mentions, location); const { canSubmit, showStop, submitHint } = resolveComposerSubmitState({ @@ -185,6 +190,7 @@ export function SessionComposer({ if (!canSubmit) return; inputRef.current?.blur(); setFocused(false); + setPickerHold(false); Keyboard.dismiss(); markSent(); onSubmit(submitIntent); @@ -236,7 +242,19 @@ export function SessionComposer({ focusOnMount={focusOnMount} inputRef={inputRef} onChangeText={changeDraft} - onFocusChange={setFocused} + onFocusChange={(nextFocused) => { + setFocused(nextFocused); + // Only focus from a tap on the prompt takes over the hold. A closing sheet can + // move focus through the prompt on its own, and that must not fold the composer. + // Released on focus, not on touch-down, so the toolbar never folds mid-tap. + // A screen-reader activation may not report a press; the hold then lasts until + // send, which only keeps the toolbar showing. + if (nextFocused && promptTapped.current) setPickerHold(false); + promptTapped.current = false; + }} + onPressIn={() => { + promptTapped.current = true; + }} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} selection={selection} /> @@ -275,16 +293,28 @@ export function SessionComposer({ model={model} modelSelectorRef={modelSelectorRef} onAgentPress={() => { + setPickerHold(true); + // An earlier touch that never focused the prompt (a drag) must not count. + promptTapped.current = false; + // Blur explicitly: Android can hide the keyboard and leave the input focused, + // and the next tap would then never fire the focus that releases the hold. + inputRef.current?.blur(); Keyboard.dismiss(); setAgentSearch(""); pickerReturnFocusRef.current = agentSelectorRef; setOptionsPage("agent"); }} onModelPress={() => { + setPickerHold(true); + promptTapped.current = false; + inputRef.current?.blur(); Keyboard.dismiss(); setModelPickerOpen(true); }} onVariantPress={() => { + setPickerHold(true); + promptTapped.current = false; + inputRef.current?.blur(); Keyboard.dismiss(); pickerReturnFocusRef.current = variantSelectorRef; setOptionsPage("variant"); @@ -400,6 +430,7 @@ function PromptInput({ inputRef, onChangeText, onFocusChange, + onPressIn, onSelectionChange, selection, }: { @@ -410,6 +441,7 @@ function PromptInput({ inputRef: RefObject; onChangeText: (draft: string) => void; onFocusChange: (focused: boolean) => void; + onPressIn: () => void; onSelectionChange: (event: NativeSyntheticEvent) => void; selection: DraftSelection; }) { @@ -428,6 +460,7 @@ function PromptInput({ onBlur={() => onFocusChange(false)} onChangeText={onChangeText} onFocus={() => onFocusChange(true)} + onPressIn={onPressIn} onSelectionChange={onSelectionChange} placeholder="Message" placeholderTextColor={palette.dim} diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 64ed176..547016e 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -1587,6 +1587,25 @@ test("pending prompt status is announced on iOS as it changes", () => { announce.mockRestore(); }); +test("shows a model change inline with its variant, with nothing to expand", () => { + render( + , + ); + expect(screen.getByText(/Model changed to/)).toHaveTextContent("Model changed to model-2 · high"); + // Screen readers hear a pause, not the separator glyph. + expect(screen.getByText(/Model changed to/).props.accessibilityLabel).toBe( + "Model changed to model-2, high", + ); + expect(screen.queryByRole("button")).toBeNull(); +}); + test("transcript notices and disclosure labels match the chat activity size", () => { render( @@ -1616,7 +1635,7 @@ test("transcript notices and disclosure labels match the chat activity size", () /> , ); - expect(screen.getByText("Model changed")).toHaveStyle({ + expect(screen.getByText(/Model changed to/)).toHaveStyle({ fontSize: typography.chatActivity.fontSize, }); expect(screen.getByText(/Agent changed to/)).toHaveStyle({ diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index f0e9b99..3281700 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -654,8 +654,25 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ ); - case "model-switched": - return ; + case "model-switched": { + // Inline like the agent change: the new model is the whole story, so nothing to expand. + const model = sanitizeTranscriptText(message.model.id, 256); + const variant = message.model.variant + ? sanitizeTranscriptText(message.model.variant, 128) + : undefined; + return ( + + + Model changed to{" "} + {variant ? `${model} · ${variant}` : model} + + + ); + } case "location-switched": return ( + act(() => target.props.onLayout({ nativeEvent: { layout: { height } } })); + const transcriptPadding = () => + StyleSheet.flatten(screen.getByLabelText("Session transcript").props.contentContainerStyle) + .paddingTop; + expect(dock.props.pointerEvents).toBe("box-none"); + expect(content.props.pointerEvents).toBe("box-none"); + expect(floatingRow.props.pointerEvents).toBe("box-none"); + expect(reservedDock.props.pointerEvents).toBe("box-none"); + + layout(reservedDock, 120); + layout(reservedDock, 120.4); expect(spacerHeight()).toBe(120); fireEvent(dock, "layout", { nativeEvent: { layout: { height: 100 } } }); expect(measureInWindow).toHaveBeenCalledTimes(1); + layout(floatingRow, 44); + expect(spacerHeight()).toBe(120); + expect(transcriptPadding()).toBe(44); + expect(screen.getByLabelText("Session transcript").props.scrollIndicatorInsets).toEqual({ + top: 44, + }); + act(() => keyboardListener("keyboardWillChangeFrame")({ endCoordinates: { screenY: 500 } })); expect(dock).toHaveStyle({ bottom: 268 }); expect(spacerHeight()).toBe(388); @@ -2764,6 +2786,10 @@ test("measures the iOS composer dock and lifts it above the keyboard", async () act(() => keyboardListener("keyboardWillHide")({})); expect(dock).toHaveStyle({ bottom: 0 }); expect(spacerHeight()).toBe(120); + // The row going away changes the padding, never the transcript's viewport. + layout(floatingRow, 0); + expect(spacerHeight()).toBe(120); + expect(transcriptPadding()).toBe(0); } finally { addListener.mockRestore(); view.unmount(); diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index b2f04fe..56dd6e1 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -1112,7 +1112,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { const changesTab = selectedTab === "changes"; const composerDockContent = ( - + - + > + + ); @@ -1197,6 +1200,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { + {/* The floating row stays out of the spacer, so the transcript scrolls under it. */} {composerDockContent} @@ -1573,6 +1578,8 @@ function useComposerDock(screenHeight: number) { const [composerDockHeight, setComposerDockHeight] = useState(66); const [composerDockScreenBottom, setComposerDockScreenBottom] = useState(0); const [composerKeyboardOffset, setComposerKeyboardOffset] = useState(0); + const [floatingRowHeight, setFloatingRowHeight] = useState(0); + const [reservedDockHeight, setReservedDockHeight] = useState(66); const composerDockRef = useRef(null); const measuredComposerDockScreenHeightRef = useRef(undefined); @@ -1600,13 +1607,30 @@ function useComposerDock(screenHeight: number) { setComposerDockHeight((current) => (Math.abs(current - height) < 1 ? current : height)); } + function measureFloatingRow(event: LayoutChangeEvent) { + const height = event.nativeEvent.layout.height; + setFloatingRowHeight((current) => (Math.abs(current - height) < 1 ? current : height)); + } + + function measureReservedDock(event: LayoutChangeEvent) { + const height = event.nativeEvent.layout.height; + setReservedDockHeight((current) => (Math.abs(current - height) < 1 ? current : height)); + } + return { composerDockHeight, composerDockRef, composerDockScreenBottom, composerKeyboardOffset, + // The top of the dock that floats over the transcript instead of reserving space. + floatingRowHeight, measureComposerContent, measureComposerDock, + measureFloatingRow, + measureReservedDock, + // The rest of the dock, measured in one piece so the floating row never resizes the + // transcript when it appears or disappears. + reservedDockHeight, setComposerKeyboardOffset, }; } @@ -1726,6 +1750,7 @@ function requestAttention( function SessionDockPanel({ activeSessions, + children, client, connectionId, execution, @@ -1733,8 +1758,10 @@ function SessionDockPanel({ formLocations, messages, monitorStale, + onFloatingRowLayout, onOpenChild, onReplyPermission, + onReservedLayout, onStopChild, permissions, replyingPermissionId, @@ -1747,6 +1774,8 @@ function SessionDockPanel({ tablet, }: { activeSessions: SessionExecution["activeSessions"]; + // The composer, measured with the execution panel as the dock's reserved part. + children: ReactNode; client: OpenCodeClient | undefined; connectionId: string; execution: SessionExecution; @@ -1754,8 +1783,10 @@ function SessionDockPanel({ formLocations: WorkspaceSelection["formLocations"]; messages: SessionMessages; monitorStale: boolean; + onFloatingRowLayout: (event: LayoutChangeEvent) => void; onOpenChild: (sessionID: string) => void; onReplyPermission: WorkspaceSelection["replyPermission"]; + onReservedLayout: (event: LayoutChangeEvent) => void; onStopChild: ((sessionID: string) => Promise) | undefined; permissions: WorkspaceSelection["permissions"]; replyingPermissionId: WorkspaceSelection["replyingPermissionId"]; @@ -1769,50 +1800,56 @@ function SessionDockPanel({ }) { return ( <> - - + + + + + + 0 ? ( + + ) : undefined + } + inbox={execution.inbox} + onAllowRetry={execution.allowRetry} + onCancelInbox={execution.cancelInbox} + onCheckAdmission={execution.reconcileAdmission} + onInterrupt={execution.interrupt} + onBackground={execution.background} + onReplyPermission={onReplyPermission} + onSteerInbox={execution.steerInbox} + failedPermissionReplyId={failedPermissionReplyId} + permissions={permissions} + projectedMessageIds={execution.projectedMessageIds} + replyingPermissionId={replyingPermissionId} /> - - 0 ? ( - - ) : undefined - } - inbox={execution.inbox} - onAllowRetry={execution.allowRetry} - onCancelInbox={execution.cancelInbox} - onCheckAdmission={execution.reconcileAdmission} - onInterrupt={execution.interrupt} - onBackground={execution.background} - onReplyPermission={onReplyPermission} - onSteerInbox={execution.steerInbox} - failedPermissionReplyId={failedPermissionReplyId} - permissions={permissions} - projectedMessageIds={execution.projectedMessageIds} - replyingPermissionId={replyingPermissionId} - /> + {children} + ); } @@ -1894,6 +1931,7 @@ function SessionDockComposer({ function SessionTranscriptList({ autoFillTranscript, + bottomInset, canLoadOlder, execution, follow, @@ -1916,6 +1954,8 @@ function SessionTranscriptList({ waitingFor, }: { autoFillTranscript: boolean; + // Room under the newest row for the dock row that floats over the transcript. + bottomInset: number; canLoadOlder: boolean; execution: SessionExecution; follow: TranscriptLiveFollow; @@ -1950,7 +1990,12 @@ function SessionTranscriptList({ return ( + {children} ); @@ -2187,6 +2238,7 @@ function ComposerDock({ children, dock }: { children: ReactNode; dock: ComposerD diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index fdd9205..25802e5 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,34 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-11-14: floating background-work chip and card, pending device verification + +iPhone screenshots showed the background-work chip in an opaque band above the +composer, the composer folding after an agent choice, and model changes behind +a disclosure. The chip now floats over the transcript: only the rest of the dock +reserves space, measured in one piece, and the inverted list pads its newest +end by the chip's height. Touches beside the chip reach the transcript. + +The chip spins while a subagent runs, shows a still terminal glyph when only +shells run, and keeps the warm dot when something needs the user. Tapping it +opens a card over the chip instead of a sheet: the screen dims, tapping outside +closes it, and a shell's page opens inside the card. The card is measured from +the chip again on rotation, resize, keyboard changes and once shown. It stays +within the safe area and below the status bar. + +After an agent, model or variant choice, the composer stays expanded until the +prompt is tapped and blurs, or the prompt is sent. Model changes read inline as +"Model changed to" the model and variant. + +The full lint, typecheck, test, and build sequence passed, including 1575 +mobile tests and both Hermes exports. Expo Doctor passed 18 of 18 checks. Every +changed file is JavaScript. Revisions were published to preview through local +EAS Update; on an iPhone the chip floated over the transcript. Still unverified +on devices: the final revision; whether the composer stays expanded after an +agent choice; Android `measureInWindow` alignment with the status-bar-translucent +card; the card with the keyboard open, in landscape and with large text; and +VoiceOver and TalkBack focus when the card opens and closes. + ## 2026-11-14: background shells in the composer chip, pending device verification The chip above the composer now counts running background shells next to From 09c0290924d1fb24a4f876530375819e87556473 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sat, 14 Nov 2026 23:52:30 +0100 Subject: [PATCH 2/2] Let shell output fill the card and open variants as a card The shell page inside the background-work card kept the sheet's 360pt output cap, so the output stayed small in a tall card. In the card the output now grows into the space the page leaves. Choosing a variant opened a full-screen sheet for a handful of options. It now opens as a card just above the Variant button, headed with the model name. The card gains an "above" placement for this. A session-screen test now chooses an agent through the real dock and checks that the composer stays expanded. --- apps/mobile/src/components/popover-card.tsx | 29 ++++--- .../session-background-shell-detail.tsx | 21 ++++- .../screens/session-background-tasks.test.tsx | 2 + .../src/screens/session-background-tasks.tsx | 1 + .../src/screens/session-composer.test.tsx | 6 +- apps/mobile/src/screens/session-composer.tsx | 80 ++++++++++++------- .../workspace-screen.integration.test.tsx | 47 +++++++++++ 7 files changed, 139 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/components/popover-card.tsx b/apps/mobile/src/components/popover-card.tsx index bd5efdf..20f5737 100644 --- a/apps/mobile/src/components/popover-card.tsx +++ b/apps/mobile/src/components/popover-card.tsx @@ -28,12 +28,15 @@ export function PopoverCard({ label, onClose, onRequestClose, + placement = "cover", returnFocusRef, scrollable = true, visible, }: { - // The control the card opens from; its bottom edge pins the card's bottom edge. + // The control the card opens from. "cover" pins the card's bottom edge to the control's + // bottom edge, over it; "above" opens just above it, leaving the control in view. anchorRef: RefObject; + placement?: "cover" | "above"; children: ReactNode; // Use the full available height, for content that sizes itself to its container. fill?: boolean; @@ -48,12 +51,16 @@ export function PopoverCard({ }) { const { height, width } = useWindowDimensions(); const insets = useSafeAreaInsets(); - const { anchorBottom, measure } = useAnchorBottom(anchorRef, visible, width, height); + const { anchor, measure } = useAnchorFrame(anchorRef, visible, width, height); const cardRef = useRef(null); // iOS returns focus in onDismiss; Android has no dismiss event, so it returns on close. useReturnFocusAfterClose(visible, returnFocusRef); - const bottomEdge = anchorBottom ?? height - insets.bottom - space.xl; + const bottomEdge = anchor + ? placement === "above" + ? anchor.top - space.xs + : anchor.bottom + : height - insets.bottom - space.xl; const bottom = Math.max(height - bottomEdge, insets.bottom + space.sm); // Never taller than the space above its bottom edge, so it can't run under the status bar. const maxHeight = Math.max(0, height - bottom - insets.top - space.md); @@ -119,28 +126,28 @@ export function PopoverCard({ ); } -// Where the anchor's bottom edge sits in the window while the card is open. Measured again -// when the window changes size or the keyboard moves, so the card keeps covering the anchor. -function useAnchorBottom( +// Where the anchor sits in the window while the card is open. Measured again when the window +// changes size or the keyboard moves, so the card keeps its place against the anchor. +function useAnchorFrame( anchorRef: RefObject, visible: boolean, width: number, height: number, ) { - const [anchorBottom, setAnchorBottom] = useState(); + const [anchor, setAnchor] = useState<{ bottom: number; top: number }>(); const measure = () => anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => { - if (anchorHeight > 0) setAnchorBottom(y + anchorHeight); + if (anchorHeight > 0) setAnchor({ bottom: y + anchorHeight, top: y }); }); // biome-ignore lint/correctness/useExhaustiveDependencies: width and height re-run the measure on rotation and resize. useEffect(() => { if (!visible) { - setAnchorBottom(undefined); + setAnchor(undefined); return; } const remeasure = () => anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => { - if (anchorHeight > 0) setAnchorBottom(y + anchorHeight); + if (anchorHeight > 0) setAnchor({ bottom: y + anchorHeight, top: y }); }); remeasure(); const shown = Keyboard.addListener("keyboardDidShow", remeasure); @@ -150,7 +157,7 @@ function useAnchorBottom( hidden.remove(); }; }, [anchorRef, visible, width, height]); - return { anchorBottom, measure }; + return { anchor, measure }; } const styles = StyleSheet.create({ diff --git a/apps/mobile/src/screens/session-background-shell-detail.tsx b/apps/mobile/src/screens/session-background-shell-detail.tsx index 35048e8..a0af1fd 100644 --- a/apps/mobile/src/screens/session-background-shell-detail.tsx +++ b/apps/mobile/src/screens/session-background-shell-detail.tsx @@ -34,11 +34,14 @@ const commandMaxHeight = 96; // output, and Stop. Once it ends, Stop gives way to how it ended. It stays on screen after the // shell leaves the running list, until Back or the sheet closes. export function BackgroundShellDetail({ + fill = false, onBack, onStop, shell, stale, }: { + // In a fixed-height container the output box grows into the space the page leaves. + fill?: boolean; onBack: () => void; onStop?: ((shellID: string) => Promise) | undefined; shell: ShellCandidate; @@ -64,7 +67,7 @@ export function BackgroundShellDetail({ variant="text" /> @@ -88,6 +91,7 @@ export function BackgroundShellDetail({ {(observation) => ( onStop(shell.shellID) : undefined} shell={shell} @@ -103,6 +107,7 @@ export function BackgroundShellDetail({ function ShellState({ error, + fill, onRetry, onStop, shell, @@ -110,6 +115,7 @@ function ShellState({ stale, }: { error: boolean; + fill: boolean; onRetry: (() => void) | undefined; onStop: (() => Promise) | undefined; shell: ShellCandidate; @@ -132,7 +138,7 @@ function ShellState({ useAnnouncement(stop.failed && stopFailedMessage); const info = snapshot?.info; return ( - + Promise) | undefined function ShellTail({ ended, error, + fill, loaded, output, stale, @@ -259,6 +267,7 @@ function ShellTail({ }: { ended: boolean; error: boolean; + fill: boolean; loaded: boolean; output: string; stale: boolean; @@ -276,7 +285,7 @@ function ShellTail({ ? "No output" : "No output yet"; return ( - + {tail.text && (tail.clipped || truncated) ? ( Showing recent output. @@ -296,7 +305,8 @@ function ShellTail({ contentSize.height - layoutMeasurement.height - contentOffset.y <= followThreshold; }} scrollEventThrottle={100} - style={styles.output} + style={[styles.output, fill && styles.outputFill]} + testID="shell-output" > {tail.text ? ( @@ -320,6 +330,7 @@ const styles = StyleSheet.create({ empty: { ...typography.footnote, color: palette.dim, padding: 12 }, error: { ...typography.footnote, color: palette.danger, flexShrink: 1 }, errorRow: { alignItems: "center", flexDirection: "row", flexWrap: "wrap", gap: space.sm }, + fill: { flexGrow: 1 }, failed: { color: palette.danger }, heading: { ...typography.headline, color: palette.ink }, output: { @@ -329,6 +340,8 @@ const styles = StyleSheet.create({ borderWidth: StyleSheet.hairlineWidth, maxHeight: 360, }, + // Grows into the card instead of the sheet's fixed cap. + outputFill: { flexGrow: 1, flexBasis: 0, maxHeight: undefined, minHeight: 120 }, outputText: { ...typography.code, color: palette.dim, padding: 12 }, page: { gap: space.sm }, pageScroll: { flex: 1 }, diff --git a/apps/mobile/src/screens/session-background-tasks.test.tsx b/apps/mobile/src/screens/session-background-tasks.test.tsx index a0bf41f..ff00a7e 100644 --- a/apps/mobile/src/screens/session-background-tasks.test.tsx +++ b/apps/mobile/src/screens/session-background-tasks.test.tsx @@ -652,6 +652,8 @@ test("tapping outside the card closes it from the shell page, and the chip opens />, ); await openShellPage(); + // In the card the output grows into the space left, instead of the sheet's fixed cap. + expect(screen.getByTestId("shell-output")).toHaveStyle({ flexGrow: 1, maxHeight: undefined }); fireEvent.press(screen.getByTestId("popover-backdrop", { includeHiddenElements: true })); expect(screen.UNSAFE_getByType(Modal).props.visible).toBe(false); fireEvent.press(screen.getByRole("button", { name: "1 shell, running in this session" })); diff --git a/apps/mobile/src/screens/session-background-tasks.tsx b/apps/mobile/src/screens/session-background-tasks.tsx index 82a82e6..02f7506 100644 --- a/apps/mobile/src/screens/session-background-tasks.tsx +++ b/apps/mobile/src/screens/session-background-tasks.tsx @@ -266,6 +266,7 @@ function SubagentMonitorSheet({ {openShell ? ( { />, ); fireEvent.press(screen.getByRole("button", { name: "Variant: deep" })); - expect(screen.getByText("Choose variant")).toBeOnTheScreen(); - expect(screen.getAllByText("Model One")).toHaveLength(2); + // A few variants are a quick choice: a card above the toolbar, not a full-screen sheet. + expect(screen.getByLabelText("Choose variant")).toBeOnTheScreen(); + expect(screen.getByRole("header", { name: "Variant · Model One" })).toBeOnTheScreen(); + expect(screen.queryByRole("button", { name: "Close Choose variant" })).toBeNull(); expect(screen.getByLabelText("deep")).toBeSelected(); fireEvent.press(screen.getByRole("button", { name: "Default" })); expect(onModelChange).toHaveBeenCalledWith({ id: "model-1", providerID: "provider" }); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index f350838..9a55435 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -32,6 +32,7 @@ import { } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; +import { PopoverCard } from "../components/popover-card"; import { SearchField } from "../components/search-field"; import { useDelayedVisibility } from "../components/use-delayed-visibility"; import { @@ -673,34 +674,52 @@ function ComposerOptionsSheet({ selectedModel: ModelInfo | undefined; }) { const agentPickerOpen = page === "agent"; + const variantHeading = useRef(null); return ( - - {agentPickerOpen ? ( - - ) : null} - {page === "variant" && selectedModel ? ( - - ) : null} - + <> + {/* A handful of variants is a quick choice: a card over the toolbar, not a full page. */} + + + {selectedModel ? `Variant · ${selectedModel.name}` : "Select a model first"} + + {selectedModel ? ( + + ) : null} + + + {agentPickerOpen ? ( + + ) : null} + + ); } @@ -763,7 +782,7 @@ function VariantOptions({ selectedModel: ModelInfo; }) { return ( - + ))} - + ); } @@ -1094,7 +1113,8 @@ const styles = StyleSheet.create({ marginLeft: space.sm, }, selectorDisabled: { opacity: disabledOpacity }, - optionsContent: { gap: 2 }, + variantHeading: { ...typography.sectionLabel, color: palette.dim, paddingHorizontal: space.md }, + optionsContent: { gap: 2, paddingHorizontal: space.sm }, completion: { backgroundColor: palette.background, borderColor: palette.border, diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 457c403..a59d496 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -2925,3 +2925,50 @@ test("offers Retry when the transcript fails to load", async () => { queryClient.clear(); } }); + +test("the composer stays expanded after choosing an agent on the session screen", async () => { + mockListAgents.mockResolvedValue({ + data: [{ hidden: false, id: "build", mode: "primary", name: "Build" }], + location: { directory: "/workspace" }, + } as never); + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { networkMode: "always" }, + queries: { gcTime: Infinity, retry: false }, + }, + }); + const view = render( + + + , + ); + try { + await waitFor(() => expect(screen.getByLabelText("Prompt").props.editable).toBe(true)); + const prompt = screen.getByLabelText("Prompt"); + fireEvent(prompt, "pressIn"); + fireEvent(prompt, "focus"); + const agentButton = await screen.findByRole("button", { name: /^Agent:/ }); + fireEvent.press(agentButton); + fireEvent(screen.getByLabelText("Prompt"), "blur"); + const results = await screen.findByLabelText("Agent results"); + fireEvent.press(within(results).getByRole("button", { name: "Build" })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByRole("button", { name: /^Agent:/ })).toBeOnTheScreen(); + } finally { + view.unmount(); + queryClient.clear(); + } +});