From 6b0b45fc72f1c7b2a86010762948eb5815a9f757 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sat, 14 Nov 2026 11:50:56 +0100 Subject: [PATCH] Show running background shells above the composer (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer chip now counts running background shells next to subagents, with desktop's wording ("1 shell", "1 subagent · 2 shells"). Shells come from transcript shell calls that started in, or moved to, the background; the server's shell status decides whether each still runs. Foreground shells stay in the transcript. A shell row opens a page in the sheet with its command, run time, the last 40 lines of output, and a confirmed Stop that removes the shell on the server. Once it ends, Stop gives way to how it ended. Android back returns to the list; an iOS swipe closes the sheet. Stale connections and failed refreshes read as last known, without Stop. To match the OpenCode iOS app, collapsed tool groups read "Used N tools" with no status or chevron, background subagent launches fold into them, and only the Working line and the chip animate. The chip announces background subagent changes once, and the ticking run time stays outside every live region. --- apps/mobile/src/components/modal-sheet.tsx | 8 +- .../mobile/src/components/subagent-status.tsx | 89 ++- .../src/components/working-indicator.tsx | 27 +- .../session-background-shell-detail.tsx | 356 ++++++++++ .../session-background-shells-model.test.ts | 153 +++++ .../session-background-shells-model.ts | 156 +++++ .../screens/session-background-tasks.test.tsx | 614 +++++++++++++++++- .../src/screens/session-background-tasks.tsx | 331 ++++++++-- .../session-shell-output-model.test.ts | 4 +- .../src/screens/session-shell-output-model.ts | 2 + .../src/screens/session-shell-output.tsx | 106 ++- .../src/screens/session-transcript.test.tsx | 82 ++- .../mobile/src/screens/session-transcript.tsx | 170 ++--- .../workspace-screen.integration.test.tsx | 16 +- apps/mobile/src/screens/workspace-screen.tsx | 37 +- apps/mobile/src/state/open-code-query-keys.ts | 4 + docs/COMPATIBILITY.md | 29 + packages/opencode-adapter/src/index.test.ts | 16 + packages/opencode-adapter/src/index.ts | 10 + packages/test-fixtures/src/index.ts | 9 +- 20 files changed, 1985 insertions(+), 234 deletions(-) create mode 100644 apps/mobile/src/screens/session-background-shell-detail.tsx create mode 100644 apps/mobile/src/screens/session-background-shells-model.test.ts create mode 100644 apps/mobile/src/screens/session-background-shells-model.ts 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 ( + +