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..20f5737 --- /dev/null +++ b/apps/mobile/src/components/popover-card.tsx @@ -0,0 +1,176 @@ +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, + placement = "cover", + returnFocusRef, + scrollable = true, + visible, +}: { + // 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; + 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 { 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 = 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); + 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 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 [anchor, setAnchor] = useState<{ bottom: number; top: number }>(); + const measure = () => + anchorRef.current?.measureInWindow((_x, y, _width, 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) { + setAnchor(undefined); + return; + } + const remeasure = () => + anchorRef.current?.measureInWindow((_x, y, _width, anchorHeight) => { + if (anchorHeight > 0) setAnchor({ bottom: y + anchorHeight, top: y }); + }); + remeasure(); + const shown = Keyboard.addListener("keyboardDidShow", remeasure); + const hidden = Keyboard.addListener("keyboardDidHide", remeasure); + return () => { + shown.remove(); + hidden.remove(); + }; + }, [anchorRef, visible, width, height]); + return { anchor, 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-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 72cb439..ff00a7e 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()); + // 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" })); expect(screen.UNSAFE_getByType(Modal).props.visible).toBe(true); @@ -845,6 +870,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 +881,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..02f7506 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,34 @@ function SubagentMonitorSheet({ onClose(); }; return ( - + {stale ? ( + + Status unavailable · showing last known + + ) : null} {openShell ? ( - + + + ) : ( <> {sections.map((section) => @@ -319,7 +358,7 @@ function SubagentMonitorSheet({ ) : null} )} - + ); } @@ -523,13 +562,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 +583,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..f652c93 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"); @@ -689,8 +736,10 @@ test("names the selected model in the variant picker", () => { />, ); 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 1f62fcc..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 { @@ -158,11 +159,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 +191,7 @@ export function SessionComposer({ if (!canSubmit) return; inputRef.current?.blur(); setFocused(false); + setPickerHold(false); Keyboard.dismiss(); markSent(); onSubmit(submitIntent); @@ -236,7 +243,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 +294,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 +431,7 @@ function PromptInput({ inputRef, onChangeText, onFocusChange, + onPressIn, onSelectionChange, selection, }: { @@ -410,6 +442,7 @@ function PromptInput({ inputRef: RefObject; onChangeText: (draft: string) => void; onFocusChange: (focused: boolean) => void; + onPressIn: () => void; onSelectionChange: (event: NativeSyntheticEvent) => void; selection: DraftSelection; }) { @@ -428,6 +461,7 @@ function PromptInput({ onBlur={() => onFocusChange(false)} onChangeText={onChangeText} onFocus={() => onFocusChange(true)} + onPressIn={onPressIn} onSelectionChange={onSelectionChange} placeholder="Message" placeholderTextColor={palette.dim} @@ -640,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} + + ); } @@ -730,7 +782,7 @@ function VariantOptions({ selectedModel: ModelInfo; }) { return ( - + ))} - + ); } @@ -1061,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/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(); @@ -2899,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(); + } +}); 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