From de7ab12c162ef5ea7306adb915a6d1ca378ec01a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 16:46:06 -0700 Subject: [PATCH 1/2] feat(mobile): show subagents in a collapsible card --- .../src/features/threads/ThreadFeed.tsx | 3 + .../subagent-card-presentation.test.ts | 44 ++++ .../threads/subagent-card-presentation.ts | 35 +++ .../threads/thread-subagent-group.tsx | 235 ++++++++++++++++++ .../src/features/threads/thread-work-log.tsx | 96 +------ apps/mobile/src/lib/threadActivity.test.ts | 112 +++++++++ apps/mobile/src/lib/threadActivity.ts | 39 ++- 7 files changed, 469 insertions(+), 95 deletions(-) create mode 100644 apps/mobile/src/features/threads/subagent-card-presentation.test.ts create mode 100644 apps/mobile/src/features/threads/subagent-card-presentation.ts create mode 100644 apps/mobile/src/features/threads/thread-subagent-group.tsx diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4d1deadb378d..3b8826b2024d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2818,6 +2818,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": + if (entry.activities[0]?.projectedItem.item.type === "subagent") { + return undefined; + } if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) { return undefined; } diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts new file mode 100644 index 000000000000..c4162282124d --- /dev/null +++ b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts @@ -0,0 +1,44 @@ +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; +import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; + +describe("subagent card", () => { + it("shows readable result text and suppresses generic completion messages", () => { + expect( + subagentCardDetail( + "- Updated `app.ts` with [the fix](https://example.com).\n- Checked tests.", + ), + ).toBe("Updated app.ts with the fix. Checked tests."); + expect(subagentCardDetail("Child task ended with status failed.")).toBeNull(); + expect(subagentCardDetail(" ")).toBeNull(); + }); + + const start = DateTime.makeUnsafe("2026-09-21T12:00:00Z"); + const end = DateTime.makeUnsafe("2026-09-21T12:01:00Z"); + const later = DateTime.makeUnsafe("2026-09-21T12:02:00Z"); + const done = { status: "completed" as const, startedAt: start, completedAt: end }; + + it("freezes settled durations and spans the group's wall time", () => { + expect(subagentCardElapsed([done], DateTime.toEpochMillis(later))).toBe("1m"); + expect( + subagentCardElapsed( + [done, { ...done, startedAt: end, completedAt: later }], + DateTime.toEpochMillis(later), + ), + ).toBe("2m"); + }); + + it("counts live work but never uses a settled agent's age as its duration", () => { + const unfinished = { ...done, completedAt: null }; + expect(subagentCardElapsed([unfinished], DateTime.toEpochMillis(later))).toBeNull(); + expect( + subagentCardElapsed([{ ...unfinished, status: "running" }], DateTime.toEpochMillis(later)), + ).toBe("2m"); + expect( + subagentCardElapsed([{ ...unfinished, status: "idle" }], DateTime.toEpochMillis(later)), + ).toBeNull(); + expect( + subagentCardElapsed([{ ...done, startedAt: null }], DateTime.toEpochMillis(later)), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.ts b/apps/mobile/src/features/threads/subagent-card-presentation.ts new file mode 100644 index 000000000000..38e2f5127113 --- /dev/null +++ b/apps/mobile/src/features/threads/subagent-card-presentation.ts @@ -0,0 +1,35 @@ +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; +import type { OrchestrationV2Subagent } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import * as DateTime from "effect/DateTime"; + +export function subagentCardDetail(detail: string | null): string | null { + if (!detail || /^Child task ended with status\b/i.test(detail)) return null; + return ( + detail + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/`/g, "") + .replace(/^[ \t]*[-*][ \t]+/gm, "") + .replace(/\s+/g, " ") + .trim() || null + ); +} + +/** A group's wall time spans its first launch to its last completion. */ +export function subagentCardElapsed( + agents: ReadonlyArray>, + nowMs: number, +): string | null { + const starts = agents.flatMap((agent) => + agent.startedAt ? [DateTime.toEpochMillis(agent.startedAt)] : [], + ); + if (starts.length === 0) return null; + const live = agents.some((agent) => isActiveSubagentStatus(agent.status)); + // Settled agents without a completion timestamp must not keep counting their age. + if (!live && agents.some((agent) => agent.completedAt === null)) return null; + const ends = agents.flatMap((agent) => + agent.completedAt ? [DateTime.toEpochMillis(agent.completedAt)] : [], + ); + const duration = (live ? nowMs : Math.max(...ends)) - Math.min(...starts); + return duration > 0 ? formatDuration(duration) : null; +} diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx new file mode 100644 index 000000000000..2398aae4fab0 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -0,0 +1,235 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useIsFocused, useNavigation } from "@react-navigation/native"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; +import { + isActiveSubagentStatus, + isTerminalSubagentStatus, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { + EnvironmentId, + OrchestrationV2Subagent, + OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { useEffect, useState } from "react"; +import { AppState, Pressable, View, type ColorValue } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { serverEnvironment } from "../../state/server"; +import { environmentThreadDetails } from "../../state/threads"; +import { SubagentStatusDot } from "./SubagentStatusDot"; +import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; +import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { WorkLogBlock } from "./work-log-layout"; + +type SubagentItem = Extract; +type AgentTiming = Pick; + +function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray }) { + const focused = useIsFocused(); + const live = agents.some((agent) => isActiveSubagentStatus(agent.status)); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setAppActive(state === "active"), + ); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (!live || !focused || !appActive) return; + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [appActive, focused, live]); + const elapsed = subagentCardElapsed(agents, nowMs); + return elapsed ? ( + {elapsed} + ) : null; +} + +function SubagentAvatar(props: { + readonly item: SubagentItem; + readonly iconUrl?: string | null | undefined; + readonly status?: OrchestrationV2Subagent["status"]; +}) { + return ( + + + {props.status ? ( + + ) : null} + + ); +} + +export function ThreadSubagentGroup(props: { + readonly activities: ReadonlyArray; + readonly environmentId: EnvironmentId; + readonly anchorKey: string; + readonly expandedRows: Readonly>; + readonly iconSubtleColor: ColorValue; + readonly onToggleRow: (rowId: string, anchorKey: string) => void; +}) { + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const navigation = useNavigation(); + const members = props.activities.flatMap(({ projectedItem }) => + projectedItem.item.type === "subagent" ? [projectedItem.item] : [], + ); + const liveAgents = useAtomValue( + environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)), + (thread) => thread?.projection.subagents, + ); + const agents = members.map((item) => { + const live = liveAgents?.find((agent) => agent.id === item.subagentId); + return { + ...item, + item, + status: live?.status ?? item.status, + startedAt: live?.startedAt ?? item.startedAt, + completedAt: live?.completedAt ?? item.completedAt, + result: live?.result ?? item.result, + progress: live?.progress ?? item.progress, + }; + }); + const label = `${agents.length} ${agents.length === 1 ? "subagent" : "subagents"}`; + const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status)); + const expanded = props.expandedRows[props.anchorKey] ?? false; + const iconUrl = (item: SubagentItem) => + config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; + return ( + + props.onToggleRow(props.anchorKey, props.anchorKey)} + className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle" + > + + {agents.slice(0, 3).map((agent, index) => ( + + + + ))} + {agents.length > 3 ? ( + + +{agents.length - 3} + + ) : null} + + + + {label} + + isActiveSubagentStatus(agent.status)) + ? "text-adaptive-sky-600-400" + : agents.some((agent) => agent.status === "failed") && "text-adaptive-rose-600-400", + )} + > + {summary} + + + + + + {expanded ? ( + + {agents.map((agent) => { + const presentation = resolveSubagentRowPresentation(agent); + const detail = subagentCardDetail( + isTerminalSubagentStatus(agent.status) + ? agent.result?.trim() || agent.progress || null + : agent.progress?.trim() || agent.result, + ); + const threadId = agent.childThreadId; + return ( + { + if (threadId !== null) + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(threadId), + }); + }} + className="min-h-14 flex-row items-center gap-3 rounded-lg px-2 py-2 active:bg-subtle" + > + + + + + {presentation.title} + + {detail && agent.status !== "completed" ? ( + + {presentation.statusLabel} + + ) : null} + + + {detail ?? presentation.statusLabel} + + + + {threadId !== null ? ( + + ) : null} + + ); + })} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 7a6aa30e2c17..988048823c5b 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,7 +1,5 @@ import { SubagentStatusDot } from "./SubagentStatusDot"; -import { useAtomValue } from "@effect/atom-react"; -import { serverEnvironment } from "../../state/server"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ThreadSubagentGroup } from "./thread-subagent-group"; import { WorkLogLabel, WorkLogBlock, @@ -20,11 +18,7 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { MaskedView } from "@expo/ui/community/masked-view"; import type { LegendListRef } from "@legendapp/list/react-native"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; -import { useIsFocused, useNavigation } from "@react-navigation/native"; -import { - formatSubagentDisplayTitle, - subagentGroupSummary, -} from "@t3tools/client-runtime/state/subagent-display"; +import { useIsFocused } from "@react-navigation/native"; import { memo, useCallback, @@ -525,92 +519,6 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { ); } -function ThreadSubagentGroup(props: ThreadWorkLogProps) { - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); - const navigation = useNavigation(); - const members = props.activities.flatMap(({ projectedItem }) => - projectedItem.item.type === "subagent" ? [projectedItem.item] : [], - ); - const summary = subagentGroupSummary(members); - const expanded = props.expandedRows[props.anchorKey] ?? false; - return ( - - props.onToggleRow(props.anchorKey, props.anchorKey)} - rowSizing={props.rowSizing} - > - - - - {summary.label} - - - {expanded ? ( - - {members.map((item) => { - const title = formatSubagentDisplayTitle(item.title ?? "Subagent"); - const threadId = item.childThreadId; - return ( - { - if (threadId !== null) - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(threadId), - }); - }} - rowSizing={props.rowSizing} - > - - provider.instanceId === item.providerInstanceId, - )?.iconUrl - } - size={14} - /> - - - {title} - - ); - })} - - ) : null} - - ); -} - function ThreadWorkGroupList(props: { readonly activities: ReadonlyArray; readonly edgeFadeColor: string; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 245d60b980e4..c59a41b39692 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId, ProviderDriverKind, ProviderThreadId, + ProviderTurnId, RunId, RunAttemptId, ScheduledTaskId, @@ -1431,6 +1432,117 @@ describe("retained v2 feed presentation", () => { }, ); + it.each([ + { envelope: "direct", output: { taskId: "a" } }, + { envelope: "structured", output: { structuredContent: { taskId: "a" } } }, + { envelope: "text", output: { content: [{ type: "text", text: '{"taskId":"a"}' }] } }, + ])( + "folds matched $envelope delegations without hiding pending, failed or unmatched calls", + ({ output }) => { + const agent = ( + id: string, + index: number, + origin = "app_owned" as "app_owned" | "provider_native", + ) => + projected( + { + ...base(id, "2026-06-20T00:00:01.000Z", index), + type: "subagent", + subagentId: NodeId.make(id), + origin, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: ThreadId.make(`child-${id}`), + prompt: "Identical task", + result: "Done", + }, + index, + ); + const delegation = ( + id: string, + index: number, + overrides: Partial> = {}, + ) => + projected( + { + ...base(id, "2026-06-20T00:00:02.000Z", index), + type: "dynamic_tool", + toolName: "t3-code.delegate_task", + input: { task: "Identical task" }, + output, + ...overrides, + }, + index, + ); + const feed = buildThreadFeed([ + agent("a", 1), + delegation("matched", 2), + agent("b", 3), + delegation("pending", 4, { status: "running", output: null }), + delegation("unmatched", 5, { output: { taskId: "missing" } }), + delegation("failed", 6, { status: "failed" }), + delegation("error-output", 7, { output: { taskId: "a", isError: true } }), + delegation("other-run", 8, { runId: RunId.make("other-run") }), + agent("native", 9, "provider_native"), + delegation("native-delegation", 10, { output: { taskId: "native" } }), + ]); + const groups = feed.flatMap((entry) => + entry.type === "activity-group" + ? [entry.activities.map((activity) => activity.projectedItem.item.id)] + : [], + ); + expect(groups[0]).toEqual(["a", "b"]); + expect(groups.flat()).toEqual([ + "a", + "b", + "pending", + "unmatched", + "failed", + "error-output", + "other-run", + "native", + "native-delegation", + ]); + const presented = deriveThreadFeedPresentation( + feed, + null, + new Set([runId, RunId.make("other-run")]), + ); + expect( + presented.find( + (entry) => + entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.id === "a", + )?.continuesWorkLog, + ).toBeUndefined(); + }, + ); + + it("keeps subagents from different provider turns in separate cards", () => { + const agent = (id: string, index: number) => + projected( + { + ...base(id, "2026-06-20T00:00:01.000Z", index), + type: "subagent", + subagentId: NodeId.make(id), + origin: "provider_native", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerTurnId: ProviderTurnId.make(id), + childThreadId: null, + prompt: "Task", + result: null, + }, + index, + ); + expect( + buildThreadFeed([agent("a", 1), agent("b", 2)]).flatMap((entry) => + entry.type === "activity-group" + ? [entry.activities.map((activity) => activity.projectedItem.item.id)] + : [], + ), + ).toEqual([["a"], ["b"]]); + }); + it("groups only adjacent subagents in the same run, keeping their child links", () => { const agent = (id: string, index: number, agentRunId = runId) => projected( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5dffda0dc16e..c974860b1bdf 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -24,6 +24,7 @@ import { type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { + resolveT3McpToolDefinition, resolveT3McpToolPresentation, type T3McpToolLogo, type T3McpToolPresentation, @@ -46,6 +47,7 @@ import type { } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; export type PendingApproval = ThreadPendingApproval; @@ -712,6 +714,15 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean { } function groupAdjacentActivities(entries: ReadonlyArray): ThreadFeedEntry[] { + const childrenByRun = new Map>(); + for (const entry of entries) { + if (entry.type !== "activity") continue; + const item = entry.activity.projectedItem.item; + if (item.type !== "subagent" || item.origin !== "app_owned" || item.runId === null) continue; + const children = childrenByRun.get(item.runId) ?? new Set(); + children.add(item.subagentId); + childrenByRun.set(item.runId, children); + } const grouped: ThreadFeedEntry[] = []; let firstActivityEntry: Extract | null = null; let openGroupActivities: ThreadFeedActivity[] = []; @@ -740,6 +751,26 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th }; for (const entry of entries) { + // A successful delegation is already represented by its durable child card. + // Pending, failed and unmatched calls remain visible, even with identical prompts. + if (entry.type === "activity") { + const item = entry.activity.projectedItem.item; + if ( + item.type === "dynamic_tool" && + item.runId !== null && + (item.status === "running" || item.status === "completed") && + resolveT3McpToolDefinition(item.toolName)?.summaryAction === "delegate" && + !workEntryDisplayIndicatesToolFailure(entry.activity.workEntry) + ) { + const output = compactDynamicToolOutput(item.output); + if ( + !output?.isError && + output?.taskId !== undefined && + childrenByRun.get(item.runId)?.has(output.taskId) + ) + continue; + } + } // Skip empty messages so they don't break activity grouping. if (isEmptyMessage(entry)) { continue; @@ -761,6 +792,9 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th (entry.activity.projectedItem.item.type === "subagent") !== (firstActivityEntry?.activity.projectedItem.item.type === "subagent") || firstActivityEntry?.runId !== entry.runId || + (entry.activity.projectedItem.item.type === "subagent" && + firstActivityEntry?.activity.projectedItem.item.providerTurnId !== + entry.activity.projectedItem.item.providerTurnId) || (entry.activity.projectedItem.item.type !== "subagent" && firstActivityEntry?.activity.attemptId !== entry.activity.attemptId) ) { @@ -1100,7 +1134,10 @@ function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean { !isContextCompactionActivityGroup(row) && !isContextHandoffActivityGroup(row) && row.activities.every( - (activity) => !activity.prominent && activity.projectedItem.item.type !== "notification", + (activity) => + !activity.prominent && + activity.projectedItem.item.type !== "notification" && + activity.projectedItem.item.type !== "subagent", ))) ); } From 93e6a6f23d90a15dc9974c81f3c60169d7219253 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 21 Sep 2026 17:11:32 -0700 Subject: [PATCH 2/2] fix(mobile): show single subagents directly --- .../threads/thread-subagent-group.tsx | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 2398aae4fab0..0c51c6195b75 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -101,60 +101,60 @@ export function ThreadSubagentGroup(props: { progress: live?.progress ?? item.progress, }; }); - const label = `${agents.length} ${agents.length === 1 ? "subagent" : "subagents"}`; + const grouped = agents.length > 1; + const label = `${agents.length} subagents`; const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status)); const expanded = props.expandedRows[props.anchorKey] ?? false; const iconUrl = (item: SubagentItem) => config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; return ( - props.onToggleRow(props.anchorKey, props.anchorKey)} - className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle" - > - - {agents.slice(0, 3).map((agent, index) => ( - - - - ))} - {agents.length > 3 ? ( - - +{agents.length - 3} - - ) : null} - - - - {label} - - isActiveSubagentStatus(agent.status)) - ? "text-adaptive-sky-600-400" - : agents.some((agent) => agent.status === "failed") && "text-adaptive-rose-600-400", - )} - > - {summary} - - - - - - {expanded ? ( + {grouped ? ( + props.onToggleRow(props.anchorKey, props.anchorKey)} + className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle" + > + + {agents.slice(0, 3).map((agent, index) => ( + + + + ))} + {agents.length > 3 ? ( + + +{agents.length - 3} + + ) : null} + + + + {label} + + isActiveSubagentStatus(agent.status)) + ? "text-adaptive-sky-600-400" + : agents.some((agent) => agent.status === "failed") && + "text-adaptive-rose-600-400", + )} + > + {summary} + + + + + + ) : null} + {!grouped || expanded ? ( {agents.map((agent) => { const presentation = resolveSubagentRowPresentation(agent);