diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx index 5b75aa2..2e94636 100644 --- a/apps/mobile/src/components/modal-sheet.tsx +++ b/apps/mobile/src/components/modal-sheet.tsx @@ -37,9 +37,13 @@ export function ModalSheet({ size = "page", returnFocusRef, closeLabel = "Done", + onRequestClose, }: { children: ReactNode; onClose: () => void; + // Android back and the iOS dismiss gesture. Defaults to onClose; a sheet with an inner page + // passes its own so back returns to the list instead of closing the sheet. + onRequestClose?: (() => void) | undefined; scrollable?: boolean; subtitle?: string; title: string; @@ -58,7 +62,7 @@ export function ModalSheet({ return ( focusAccessibility(returnFocusRef?.current)} onShow={() => focusAccessibility(headingRef.current)} presentationStyle={presentationStyle} @@ -102,7 +106,7 @@ export function ModalSheet({ ); } -function focusAccessibility(target: Text | View | null | undefined) { +export function focusAccessibility(target: Text | View | null | undefined) { const handle = target ? findNodeHandle(target) : null; if (handle) AccessibilityInfo.setAccessibilityFocus(handle); } diff --git a/apps/mobile/src/components/subagent-status.tsx b/apps/mobile/src/components/subagent-status.tsx index 1026998..85d8817 100644 --- a/apps/mobile/src/components/subagent-status.tsx +++ b/apps/mobile/src/components/subagent-status.tsx @@ -1,6 +1,6 @@ import Feather from "@expo/vector-icons/Feather"; -import { useEffect, useState } from "react"; -import { StyleSheet, Text, View } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { type StyleProp, StyleSheet, Text, type TextStyle, View } from "react-native"; import { isSubagentInFlight, @@ -33,6 +33,26 @@ export function subagentStateLabel(state: SubagentState, waitingFor?: SubagentAt } } +// What a transition is announced as: "Background subagent Review needs you". Running states +// are not announced; a child that stopped without reporting how is never called a success. +export function subagentOutcomeLabel(state: SubagentState) { + switch (state) { + case "running": + case "streaming": + return undefined; + case "waiting": + return "needs you"; + case "error": + return "failed"; + case "interrupted": + return "was interrupted"; + case "finished": + return "finished, outcome unknown"; + case "completed": + return "completed"; + } +} + // Working is blue, attention and stops amber, failure red, a finished run dim: the hue only // reinforces the label beside it. export function subagentStateColor(state: SubagentState) { @@ -61,7 +81,11 @@ export function SubagentStatusGlyph({ if (state === "running" || state === "streaming") { return ( - + {/* Still: the Working line and the chip carry the motion; the blue label says it runs. */} + ); } @@ -90,17 +114,27 @@ export function SubagentStatusGlyph({ } // Ticks once a second while the subagent runs and freezes at its finish. Screen readers skip -// it, so they are not told every new second. +// it, so they are not told every new second. Render it as a sibling of any live-region text, +// never nested inside one: Android flattens nested Text into one node, which would make the +// live region change, and be read out, every second. export function SubagentElapsed({ finishedAt, + frozen = false, startedAt, state, + style, }: { finishedAt?: number | undefined; + // Last-known state from a stale connection: show the time at the last update, without ticking. + frozen?: boolean; startedAt?: number | undefined; state: SubagentState; + style?: StyleProp; }) { - const live = isSubagentInFlight(state); + const live = isSubagentInFlight(state) && !frozen; + // Opened while already frozen, it never saw a live tick, so any time shown would be a guess. + const sawLive = useRef(!frozen); + if (!frozen) sawLive.current = true; const [now, setNow] = useState(Date.now); useEffect(() => { if (!live || startedAt === undefined) return; @@ -109,10 +143,11 @@ export function SubagentElapsed({ return () => clearInterval(timer); }, [live, startedAt]); if (startedAt === undefined) return null; - const end = live ? now : finishedAt; + if (frozen && isSubagentInFlight(state) && !sawLive.current) return null; + const end = live || (frozen && isSubagentInFlight(state)) ? now : finishedAt; if (end === undefined) return null; return ( - + {` · ${formatElapsed(end - startedAt, true)}`} ); @@ -124,6 +159,7 @@ export function SubagentMetaLine({ agent, background, finishedAt, + live = true, numberOfLines, startedAt, state, @@ -133,34 +169,47 @@ export function SubagentMetaLine({ agent?: string | undefined; background: boolean; finishedAt?: number | undefined; + // Whether state changes are read out here (Android live region). Off where another view owns + // the announcement, so one change is never read twice. + live?: boolean; numberOfLines?: number | undefined; startedAt?: number | undefined; state: SubagentState; suffix?: string | undefined; waitingFor?: SubagentAttention | undefined; }) { + // The live region holds only the state words; the ticking time is a hidden sibling. return ( - - {agent ? `${agent} · ` : ""} - {background ? "Background · " : ""} - - {subagentStateLabel(state, waitingFor)} + + + {agent ? `${agent} · ` : ""} + {background ? "Background · " : ""} + + {subagentStateLabel(state, waitingFor)} + + {suffix ?? ""} - - {suffix ?? ""} - + + ); } export const subagentStatusStyles = StyleSheet.create({ meta: { ...typography.footnote, color: palette.dim }, + metaRow: { alignItems: "baseline", flexDirection: "row", flexWrap: "wrap" }, }); const styles = StyleSheet.create({ glyph: { alignItems: "center", height: 20, justifyContent: "center", width: 16 }, + metaShrink: { flexShrink: 1 }, }); diff --git a/apps/mobile/src/components/working-indicator.tsx b/apps/mobile/src/components/working-indicator.tsx index 68bc086..3eb6ebb 100644 --- a/apps/mobile/src/components/working-indicator.tsx +++ b/apps/mobile/src/components/working-indicator.tsx @@ -17,8 +17,16 @@ 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. -export function WorkingIndicator({ color = palette.activity }: { color?: string } = {}) { +// 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. +export function WorkingIndicator({ + animated = true, + color = palette.activity, +}: { + animated?: boolean; + color?: string; +} = {}) { const [reducedMotion, setReducedMotion] = useState(true); const [foreground, setForeground] = useState(AppState.currentState === "active"); @@ -51,13 +59,14 @@ export function WorkingIndicator({ color = palette.activity }: { color?: string top: (bit % 3) * 4.5, opacity: (0x0b & (1 << bit)) !== 0 ? 1 : 0, }, - !reducedMotion && { - animationName: animation, - animationDuration: 800, - animationIterationCount: "infinite", - animationTimingFunction: steps(1, "end"), - animationPlayState: foreground ? "running" : "paused", - }, + animated && + !reducedMotion && { + animationName: animation, + animationDuration: 800, + animationIterationCount: "infinite", + animationTimingFunction: steps(1, "end"), + animationPlayState: foreground ? "running" : "paused", + }, ]} /> ))} diff --git a/apps/mobile/src/screens/session-background-shell-detail.tsx b/apps/mobile/src/screens/session-background-shell-detail.tsx new file mode 100644 index 0000000..35048e8 --- /dev/null +++ b/apps/mobile/src/screens/session-background-shell-detail.tsx @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState } from "react"; +import { + Alert, + type NativeScrollEvent, + type NativeSyntheticEvent, + ScrollView, + StyleSheet, + Text, + useWindowDimensions, + View, +} from "react-native"; + +import { Button } from "../components/button"; +import { focusAccessibility } from "../components/modal-sheet"; +import { SelectableTranscriptText } from "../components/selectable-transcript-text"; +import { SubagentElapsed } from "../components/subagent-status"; +import { useAnnouncement } from "../components/use-announcement"; +import { minTouchTarget, palette, radius, space, typeRamp, typography } from "../theme"; +import { + type ShellCandidate, + shellOutputTail, + shellPageStatus, + shortShellCommand, +} from "./session-background-shells-model"; +import { ShellObservation } from "./session-shell-output"; +import type { ShellSnapshot } from "./session-shell-output-model"; + +// Within this distance of the end, new output keeps the box scrolled to the end. +const followThreshold = 24; +// About four lines of command at the default size; grows with text size up to twice that. +const commandMaxHeight = 96; + +// One background shell inside the monitor sheet: its command, run time, the last lines of live +// 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({ + onBack, + onStop, + shell, + stale, +}: { + onBack: () => void; + onStop?: ((shellID: string) => Promise) | undefined; + shell: ShellCandidate; + stale: boolean; +}) { + const headingRef = useRef(null); + // Opening replaces the row that held focus, so focus moves to this page's heading. + useEffect(() => { + const frame = requestAnimationFrame(() => focusAccessibility(headingRef.current)); + return () => cancelAnimationFrame(frame); + }, []); + const { fontScale } = useWindowDimensions(); + // Back stays pinned; the page below it scrolls, so Back never scrolls away at large text. + return ( + +