Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/mobile/src/components/modal-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -58,7 +62,7 @@ export function ModalSheet({
return (
<Modal
animationType={reducedMotion ? "none" : "slide"}
onRequestClose={onClose}
onRequestClose={onRequestClose ?? onClose}
onDismiss={() => focusAccessibility(returnFocusRef?.current)}
onShow={() => focusAccessibility(headingRef.current)}
presentationStyle={presentationStyle}
Expand Down Expand Up @@ -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);
}
Expand Down
89 changes: 69 additions & 20 deletions apps/mobile/src/components/subagent-status.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -61,7 +81,11 @@ export function SubagentStatusGlyph({
if (state === "running" || state === "streaming") {
return (
<View style={styles.glyph} testID="subagent-glyph-working">
<WorkingIndicator color={state === "streaming" ? palette.dim : palette.info} />
{/* Still: the Working line and the chip carry the motion; the blue label says it runs. */}
<WorkingIndicator
animated={false}
color={state === "streaming" ? palette.dim : palette.info}
/>
</View>
);
}
Expand Down Expand Up @@ -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<TextStyle>;
}) {
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;
Expand All @@ -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 (
<Text accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
<Text accessibilityElementsHidden importantForAccessibility="no-hide-descendants" style={style}>
{` · ${formatElapsed(end - startedAt, true)}`}
</Text>
);
Expand All @@ -124,6 +159,7 @@ export function SubagentMetaLine({
agent,
background,
finishedAt,
live = true,
numberOfLines,
startedAt,
state,
Expand All @@ -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 (
<Text
accessibilityLiveRegion="polite"
dynamicTypeRamp="footnote"
numberOfLines={numberOfLines}
style={subagentStatusStyles.meta}
>
{agent ? `${agent} · ` : ""}
{background ? "Background · " : ""}
<Text style={{ color: subagentStateColor(state) }}>
{subagentStateLabel(state, waitingFor)}
<View style={subagentStatusStyles.metaRow}>
<Text
accessibilityLiveRegion={live ? "polite" : "none"}
dynamicTypeRamp="footnote"
numberOfLines={numberOfLines}
style={[subagentStatusStyles.meta, styles.metaShrink]}
>
{agent ? `${agent} · ` : ""}
{background ? "Background · " : ""}
<Text style={{ color: subagentStateColor(state) }}>
{subagentStateLabel(state, waitingFor)}
</Text>
{suffix ?? ""}
</Text>
<SubagentElapsed finishedAt={finishedAt} startedAt={startedAt} state={state} />
{suffix ?? ""}
</Text>
<SubagentElapsed
finishedAt={finishedAt}
startedAt={startedAt}
state={state}
style={subagentStatusStyles.meta}
/>
</View>
);
}

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 },
});
27 changes: 18 additions & 9 deletions apps/mobile/src/components/working-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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",
},
]}
/>
))}
Expand Down
Loading
Loading