authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-10-03 12:45:58-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 09:45:58-07:00
log65731f986ba2175063b0182deb6a143f8f323b74
treeb2c8a8544767f790f66987ce096c187b62ba5787
parentaad732901e4b7d485574eaef5a9c1fb388c4291a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show complete subagent details (#15189)


9 files changed, 433 insertions(+), 146 deletions(-)

apps/mobile/src/features/threads/SubagentRow.tsx created+154
...@@ -0,0 +1,154 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display";
3import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts";
4import type { ReactNode } from "react";
5import { View } from "react-native";
6
7import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";
9import { ProviderIcon } from "../../components/ProviderIcon";
10import { cn } from "../../lib/cn";
11import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities";
12import { subagentCardDetail } from "./subagent-card-presentation";
13import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot";
14import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
15
16type SubagentRowSubagent = Pick<
17 OrchestrationV2Subagent,
18 | "threadId"
19 | "childThreadId"
20 | "model"
21 | "driver"
22 | "providerInstanceId"
23 | "title"
24 | "prompt"
25 | "status"
26 | "progress"
27 | "result"
28>;
29
30/**
31 * One agent as shown in the Agents sheet and transcript cards: status, model,
32 * workspace changes, and recent progress or result. Callers own the press
33 * target and pass their own elapsed timer so ticking stays isolated.
34 */
35export function SubagentRow(props: {
36 readonly environmentId: EnvironmentId;
37 readonly subagent: SubagentRowSubagent;
38 readonly elapsed: ReactNode;
39}) {
40 const presentation = resolveSubagentRowPresentation(props.subagent);
41 const detail = subagentCardDetail(presentation.detail);
42 return (
43 <View className="flex-row gap-3">
44 <View className="h-5 justify-center">
45 <SubagentStatusDot tone={presentation.tone} placement="sheet" />
46 </View>
47 <View className="min-w-0 flex-1 gap-1">
48 <View className="min-h-5 flex-row items-center gap-2">
49 <View className="min-w-0 flex-1 flex-row items-baseline gap-1.5">
50 <Text
51 numberOfLines={1}
52 className="min-w-0 shrink font-t3-medium text-sm text-foreground"
53 >
54 {presentation.title}
55 </Text>
56 <Text
57 accessibilityElementsHidden
58 importantForAccessibility="no"
59 className="shrink-0 text-xs text-foreground-muted"
60 >
61 ·
62 </Text>
63 <Text
64 className={cn(
65 "shrink-0 text-xs font-t3-medium",
66 SUBAGENT_TONE_TEXT_CLASS[presentation.tone],
67 )}
68 >
69 {presentation.statusLabel}
70 </Text>
71 </View>
72 {props.elapsed}
73 {presentation.canOpenThread ? (
74 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
75 ) : null}
76 </View>
77 <SubagentMetadata environmentId={props.environmentId} subagent={props.subagent} />
78 {detail ? (
79 <Text
80 numberOfLines={3}
81 className={cn(
82 "text-xs text-foreground-muted",
83 presentation.tone === "failed" && SUBAGENT_TONE_TEXT_CLASS.failed,
84 )}
85 >
86 {detail}
87 </Text>
88 ) : null}
89 </View>
90 </View>
91 );
92}
93
94/** Uses shell data already held by the client, without loading child transcripts. */
95function SubagentMetadata(props: {
96 readonly environmentId: EnvironmentId;
97 readonly subagent: SubagentRowSubagent;
98}) {
99 const { environmentId, subagent } = props;
100 const config = useEnvironmentServerConfig(environmentId);
101 const provider = config?.providers.find(
102 (candidate) => candidate.instanceId === subagent.providerInstanceId,
103 );
104 const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source;
105 const child = useThreadShell(
106 subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId),
107 )?.source;
108 const parentProject = useProject(
109 parent ? scopeProjectRef(environmentId, parent.projectId) : null,
110 );
111 const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null);
112 const { modelLabel, workspace } = resolveSubagentMetadata({
113 model: subagent.model,
114 provider,
115 parentThread: parent,
116 childThread: child,
117 parentProject,
118 childProject,
119 });
120 return (
121 <View className="min-w-0 flex-row items-center gap-1.5">
122 <ProviderIcon
123 provider={provider?.driver ?? subagent.driver}
124 iconUrl={provider?.iconUrl}
125 size={12}
126 />
127 <Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
128 {provider?.displayName ? `${provider.displayName} · ` : ""}
129 {modelLabel}
130 </Text>
131 {workspace.map(({ label, value }) => (
132 // The row reads this label in place of the icon. collapsable keeps the
133 // view (and label) from being flattened away without making it a
134 // separate accessibility stop.
135 <View
136 key={label}
137 collapsable={false}
138 accessibilityLabel={`${label}: ${value}`}
139 className="min-w-0 shrink flex-row items-center gap-1.5"
140 >
141 <Text className="text-xs text-foreground-muted">·</Text>
142 <SymbolView
143 name={label === "Branch" ? "arrow.triangle.branch" : "folder"}
144 size={11}
145 tintColorClassName="accent-icon-muted"
146 />
147 <Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
148 {value}
149 </Text>
150 </View>
151 ))}
152 </View>
153 );
154}
apps/mobile/src/features/threads/SubagentStatusDot.tsx+8-2
...@@ -10,19 +10,25 @@ const TONE_CLASS = {...@@ -10,19 +10,25 @@ const TONE_CLASS = {
10 stopped: "bg-foreground-muted",10 stopped: "bg-foreground-muted",
11} as const satisfies Record<SubagentRowTone, string>;11} as const satisfies Record<SubagentRowTone, string>;
1212
13export const SUBAGENT_TONE_TEXT_CLASS = {
14 working: "text-adaptive-sky-600-400",
15 completed: "text-adaptive-emerald-600-400",
16 failed: "text-adaptive-rose-600-400",
17 stopped: "text-foreground-muted",
18} as const satisfies Record<SubagentRowTone, string>;
19
13export function SubagentStatusDot({20export function SubagentStatusDot({
14 tone,21 tone,
15 placement = "inline",22 placement = "inline",
16}: {23}: {
17 readonly tone: SubagentRowTone;24 readonly tone: SubagentRowTone;
18 readonly placement?: "inline" | "provider" | "sheet";25 readonly placement?: "inline" | "sheet";
19}) {26}) {
20 return (27 return (
21 <View28 <View
22 className={cn(29 className={cn(
23 "shrink-0 rounded-full",30 "shrink-0 rounded-full",
24 placement === "sheet" ? "h-2 w-2" : "h-1.5 w-1.5",31 placement === "sheet" ? "h-2 w-2" : "h-1.5 w-1.5",
25 placement === "provider" && "absolute bottom-0.5 right-0.5",
26 TONE_CLASS[tone],32 TONE_CLASS[tone],
27 )}33 )}
28 />34 />
apps/mobile/src/features/threads/ThreadAgentsSheet.tsx+21-26
...@@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
18import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";18import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
19import { AppText as Text } from "../../components/AppText";19import { AppText as Text } from "../../components/AppText";
20import { SymbolView } from "../../components/AppSymbol";
21import { useUniwindTheme } from "../../lib/useUniwindTheme";20import { useUniwindTheme } from "../../lib/useUniwindTheme";
22import { environmentThreadDetails } from "../../state/threads";21import { environmentThreadDetails } from "../../state/threads";
23import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";22import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
24import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";23import { SubagentRow } from "./SubagentRow";
25
26import { SubagentStatusDot } from "./SubagentStatusDot";
2724
28const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);25const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
2926
...@@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {...@@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
72 <AgentRow69 <AgentRow
73 key={subagent.id}70 key={subagent.id}
74 subagent={subagent}71 subagent={subagent}
72 environmentId={target.environmentId}
75 tickSeconds={hasLiveAgent}73 tickSeconds={hasLiveAgent}
76 onOpen={openChildThread}74 onOpen={openChildThread}
77 />75 />
...@@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {...@@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
121}119}
122120
123function AgentRow(props: {121function AgentRow(props: {
122 readonly environmentId: EnvironmentId;
124 readonly subagent: OrchestrationV2Subagent;123 readonly subagent: OrchestrationV2Subagent;
125 readonly tickSeconds: boolean;124 readonly tickSeconds: boolean;
126 readonly onOpen: (childThreadId: ThreadId) => void;125 readonly onOpen: (childThreadId: ThreadId) => void;
127}) {126}) {
128 const { subagent } = props;127 const { subagent } = props;
129 const presentation = resolveSubagentRowPresentation(subagent);
130 const childThreadId = subagent.childThreadId;128 const childThreadId = subagent.childThreadId;
131 const elapsed = useSubagentElapsed(subagent, props.tickSeconds);
132129
133 const row = (130 const row = (
134 <View className="min-h-14 flex-row items-center gap-3 border-b border-border py-3">131 <View className="border-b border-border py-3.5">
135 <SubagentStatusDot tone={presentation.tone} placement="sheet" />132 <SubagentRow
136 <View className="min-w-0 flex-1 gap-0.5">133 environmentId={props.environmentId}
137 <Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>134 subagent={subagent}
138 {presentation.title}135 elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
139 </Text>136 />
140 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
141 {presentation.detail ?? presentation.statusLabel}
142 </Text>
143 </View>
144 {elapsed === null ? null : (
145 <Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">{elapsed}</Text>
146 )}
147 {presentation.canOpenThread ? (
148 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
149 ) : null}
150 </View>137 </View>
151 );138 );
152139
...@@ -154,7 +141,6 @@ function AgentRow(props: {...@@ -154,7 +141,6 @@ function AgentRow(props: {
154 return (141 return (
155 <View142 <View
156 accessible143 accessible
157 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
158 accessibilityHint="Provider-managed agent. Its work appears in the transcript."144 accessibilityHint="Provider-managed agent. Its work appears in the transcript."
159 >145 >
160 {row}146 {row}
...@@ -165,7 +151,6 @@ function AgentRow(props: {...@@ -165,7 +151,6 @@ function AgentRow(props: {
165 return (151 return (
166 <Pressable152 <Pressable
167 accessibilityRole="link"153 accessibilityRole="link"
168 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
169 accessibilityHint="Opens this agent's thread"154 accessibilityHint="Opens this agent's thread"
170 onPress={() => props.onOpen(childThreadId)}155 onPress={() => props.onOpen(childThreadId)}
171 className="active:opacity-70"156 className="active:opacity-70"
...@@ -175,9 +160,19 @@ function AgentRow(props: {...@@ -175,9 +160,19 @@ function AgentRow(props: {
175 );160 );
176}161}
177162
163function AgentElapsed(props: {
164 readonly subagent: OrchestrationV2Subagent;
165 readonly tickSeconds: boolean;
166}) {
167 const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds);
168 return elapsed === null ? null : (
169 <Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
170 );
171}
172
178/**173/**
179 * Elapsed time for one agent. Only a roster with live work subscribes to the174 * Elapsed time for one agent. Only live work ticks, inside AgentElapsed,
180 * shared second tick, so a settled sheet never repaints.175 * so the timer never repaints the metadata or a settled sheet.
181 */176 */
182function useSubagentElapsed(177function useSubagentElapsed(
183 subagent: Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">,178 subagent: Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">,
apps/mobile/src/features/threads/thread-subagent-group.tsx+11-65
...@@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { useIsFocused, useNavigation } from "@react-navigation/native";2import { useIsFocused, useNavigation } from "@react-navigation/native";
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";4import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
5import {5import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
6 isActiveSubagentStatus,
7 isTerminalSubagentStatus,
8} from "@t3tools/client-runtime/state/subagentRuntime";
9import type {6import type {
10 EnvironmentId,7 EnvironmentId,
11 OrchestrationV2Subagent,8 OrchestrationV2Subagent,
...@@ -21,9 +18,8 @@ import { cn } from "../../lib/cn";...@@ -21,9 +18,8 @@ import { cn } from "../../lib/cn";
21import type { ThreadFeedActivity } from "../../lib/threadActivity";18import type { ThreadFeedActivity } from "../../lib/threadActivity";
22import { serverEnvironment } from "../../state/server";19import { serverEnvironment } from "../../state/server";
23import { environmentThreadDetails } from "../../state/threads";20import { environmentThreadDetails } from "../../state/threads";
24import { SubagentStatusDot } from "./SubagentStatusDot";21import { subagentCardElapsed } from "./subagent-card-presentation";
25import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";22import { SubagentRow } from "./SubagentRow";
26import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
27import { WorkLogBlock } from "./work-log-layout";23import { WorkLogBlock } from "./work-log-layout";
2824
29type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;25type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
...@@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin...@@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin
47 }, [appActive, focused, live]);43 }, [appActive, focused, live]);
48 const elapsed = subagentCardElapsed(agents, nowMs);44 const elapsed = subagentCardElapsed(agents, nowMs);
49 return elapsed ? (45 return elapsed ? (
50 <Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">{elapsed}</Text>46 <Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
51 ) : null;47 ) : null;
52}48}
5349
54function SubagentAvatar(props: {50function SubagentAvatar(props: {
55 readonly item: SubagentItem;51 readonly item: SubagentItem;
56 readonly iconUrl?: string | null | undefined;52 readonly iconUrl?: string | null | undefined;
57 readonly status?: OrchestrationV2Subagent["status"];
58}) {53}) {
59 return (54 return (
60 <View55 <View
61 accessible={false}56 accessible={false}
62 className="relative h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"57 className="h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"
63 >58 >
64 <ProviderIcon provider={props.item.driver} iconUrl={props.iconUrl} size={15} />59 <ProviderIcon provider={props.item.driver} iconUrl={props.iconUrl} size={15} />
65 {props.status ? (
66 <SubagentStatusDot
67 placement="provider"
68 tone={resolveSubagentRowPresentation({ ...props.item, status: props.status }).tone}
69 />
70 ) : null}
71 </View>60 </View>
72 );61 );
73}62}
...@@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: {...@@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: {
99 completedAt: live?.completedAt ?? item.completedAt,88 completedAt: live?.completedAt ?? item.completedAt,
100 result: live?.result ?? item.result,89 result: live?.result ?? item.result,
101 progress: live?.progress ?? item.progress,90 progress: live?.progress ?? item.progress,
91 model: live?.model ?? null,
102 };92 };
103 });93 });
104 const grouped = agents.length > 1;94 const grouped = agents.length > 1;
...@@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: {...@@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: {
157 {!grouped || expanded ? (147 {!grouped || expanded ? (
158 <View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">148 <View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
159 {agents.map((agent) => {149 {agents.map((agent) => {
160 const presentation = resolveSubagentRowPresentation(agent);
161 const detail = subagentCardDetail(
162 isTerminalSubagentStatus(agent.status)
163 ? agent.result?.trim() || agent.progress || null
164 : agent.progress?.trim() || agent.result,
165 );
166 const threadId = agent.childThreadId;150 const threadId = agent.childThreadId;
167 return (151 return (
168 <Pressable152 <Pressable
169 key={agent.item.id}153 key={agent.item.id}
170 accessible154 accessible
171 accessibilityRole={threadId === null ? undefined : "link"}155 accessibilityRole={threadId === null ? undefined : "link"}
172 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}${detail ? `, ${detail}` : ""}`}
173 accessibilityHint={156 accessibilityHint={
174 threadId === null ? "Provider-managed agent" : "Opens this agent's thread"157 threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
175 }158 }
...@@ -181,50 +164,13 @@ export function ThreadSubagentGroup(props: {...@@ -181,50 +164,13 @@ export function ThreadSubagentGroup(props: {
181 threadId: String(threadId),164 threadId: String(threadId),
182 });165 });
183 }}166 }}
184 className="min-h-14 flex-row items-center gap-3 rounded-lg px-2 py-2 active:bg-subtle"167 className="rounded-lg px-3 py-3 active:bg-subtle"
185 >168 >
186 <SubagentAvatar169 <SubagentRow
187 item={agent.item}170 environmentId={props.environmentId}
188 iconUrl={iconUrl(agent.item)}171 subagent={agent}
189 status={agent.status}172 elapsed={<SubagentElapsed agents={[agent]} />}
190 />173 />
191 <View className="min-w-0 flex-1 gap-0.5">
192 <View className="flex-row items-baseline gap-2">
193 <Text
194 numberOfLines={1}
195 className="min-w-0 shrink font-t3-medium text-sm text-foreground"
196 >
197 {presentation.title}
198 </Text>
199 {detail && agent.status !== "completed" ? (
200 <Text
201 className={cn(
202 "shrink-0 text-2xs text-foreground-muted",
203 agent.status === "failed" && "text-adaptive-rose-600-400",
204 )}
205 >
206 {presentation.statusLabel}
207 </Text>
208 ) : null}
209 </View>
210 <Text
211 numberOfLines={1}
212 className={cn(
213 "text-xs text-foreground-muted",
214 agent.status === "failed" && "text-adaptive-rose-600-400",
215 )}
216 >
217 {detail ?? presentation.statusLabel}
218 </Text>
219 </View>
220 <SubagentElapsed agents={[agent]} />
221 {threadId !== null ? (
222 <SymbolView
223 name="chevron.right"
224 size={12}
225 tintColorClassName="accent-icon-subtle"
226 />
227 ) : null}
228 </Pressable>174 </Pressable>
229 );175 );
230 })}176 })}
apps/mobile/src/features/threads/threadAgentsPresentation.test.ts+10
...@@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => {...@@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => {
65 expect(row.detail).toBeNull();65 expect(row.detail).toBeNull();
66 expect(row.statusLabel).toBe("Working");66 expect(row.statusLabel).toBe("Working");
67 });67 });
68
69 it("bounds long result previews without dropping the agent's status", () => {
70 const row = resolveSubagentRowPresentation({
71 ...base,
72 status: "failed",
73 result: "x".repeat(400),
74 });
75 expect(row.detail).toBe(`${"x".repeat(280)}…`);
76 expect(row.statusLabel).toBe("Failed");
77 });
68});78});
apps/mobile/src/features/threads/threadAgentsPresentation.ts+5-9
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
2import {1import {
3 isActiveSubagentStatus,2 formatSubagentDisplayTitle,
4 isTerminalSubagentStatus,3 subagentDetailPreview,
5} from "@t3tools/client-runtime/state/subagentRuntime";4} from "@t3tools/client-runtime/state/subagent-display";
5import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
6import type { OrchestrationV2Subagent } from "@t3tools/contracts";6import type { OrchestrationV2Subagent } from "@t3tools/contracts";
77
8const PROMPT_TITLE_LIMIT = 80;8const PROMPT_TITLE_LIMIT = 80;
...@@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation(...@@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation(
64 >,64 >,
65): SubagentRowPresentation {65): SubagentRowPresentation {
66 const live = isActiveSubagentStatus(subagent.status);66 const live = isActiveSubagentStatus(subagent.status);
67 const progress = subagent.progress?.trim() ?? "";
68 const result = subagent.result?.trim() ?? "";
69 const settled = isTerminalSubagentStatus(subagent.status);
70 const detail = settled ? result || progress : progress || result;
71 return {67 return {
72 title: rowTitle(subagent),68 title: rowTitle(subagent),
73 detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null,69 detail: subagentDetailPreview(subagent),
74 statusLabel: rowStatusLabel(subagent.status),70 statusLabel: rowStatusLabel(subagent.status),
75 tone: rowTone(subagent.status),71 tone: rowTone(subagent.status),
76 live,72 live,
apps/web/src/components/chat/SubagentTooltipContent.tsx+8-42
...@@ -1,12 +1,14 @@...@@ -1,12 +1,14 @@
1import type {1import type {
2 OrchestrationV2ThreadShell,2 OrchestrationV2ThreadShell,
3 OrchestrationV2TurnItemStatus,
3 OrchestrationProjectShell,4 OrchestrationProjectShell,
4 ServerProvider,5 ServerProvider,
5 ProviderDriverKind,6 ProviderDriverKind,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { fileBasename } from "@t3tools/client-runtime/markdown-links";8import {
8import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";9 resolveSubagentMetadata,
9import { getTriggerDisplayModelName } from "./providerIconUtils";10 subagentDetailPreview,
11} from "@t3tools/client-runtime/state/subagent-display";
10import type { ReactNode } from "react";12import type { ReactNode } from "react";
11import {13import {
12 BotIcon,14 BotIcon,
...@@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: {...@@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: {
33 childThread?: Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath"> | undefined;35 childThread?: Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath"> | undefined;
34 parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | undefined;36 parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | undefined;
35 childProject?: Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot"> | undefined;37 childProject?: Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot"> | undefined;
36 status: string;38 status: OrchestrationV2TurnItemStatus;
37 result?: string | null | undefined;39 result?: string | null | undefined;
38 progress?: string | null | undefined;40 progress?: string | null | undefined;
39}) {41}) {
40 const model = props.model?.trim();42 const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props);
41 const modelSlug = props.provider43 const preview = subagentDetailPreview(props);
42 ? resolveSelectableModel(props.provider.driver, model, props.provider.models)
43 : model;
44 const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug);
45 const modelLabel = providerModel
46 ? getTriggerDisplayModelName(providerModel)
47 : model
48 ? formatModelSlugName(model)
49 : "Not reported";
50 const currentWorkspace = props.parentThread?.worktreePath ?? props.parentProject?.workspaceRoot;
51 const childWorkspace = props.childThread?.worktreePath ?? props.childProject?.workspaceRoot;
52 const metadata = [
53 ...(props.parentThread &&
54 props.childProject &&
55 props.childProject.id !== props.parentThread.projectId
56 ? [{ label: "Project", value: props.childProject.title }]
57 : []),
58 ...(currentWorkspace && childWorkspace && currentWorkspace !== childWorkspace
59 ? [
60 {
61 label: props.childThread?.branch
62 ? "Branch"
63 : props.childThread?.worktreePath
64 ? "Worktree"
65 : "Workspace",
66 value: props.childThread?.branch ?? fileBasename(childWorkspace),
67 },
68 ]
69 : []),
70 ];
71 const settled = ["completed", "failed", "cancelled", "interrupted"].includes(props.status);
72 const result = props.result?.trim();
73 const progress = props.progress?.trim();
74 const detail = (settled ? result || progress : progress || result) || "";
75 const compactDetail = detail.trim().replace(/\s+/g, " ");
76 const preview =
77 compactDetail.length > 280 ? `${compactDetail.slice(0, 280).trimEnd()}…` : compactDetail;
78 const driver = props.provider?.driver ?? props.driver;44 const driver = props.provider?.driver ?? props.driver;
79 const working = ["running", "in_progress", "pending", "waiting"].includes(props.status);45 const working = ["running", "in_progress", "pending", "waiting"].includes(props.status);
80 const failed = ["failed", "error"].includes(props.status);46 const failed = ["failed", "error"].includes(props.status);
packages/client-runtime/src/state/subagentDisplay.test.ts+129-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { ProjectId, ProviderDriverKind } from "@t3tools/contracts";
1import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts";2import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
3import { subagentGroupSummary } from "./subagentDisplay.js";4import {
5 subagentGroupSummary,
6 resolveSubagentMetadata,
7 subagentDetailPreview,
8} from "./subagentDisplay.js";
49
5describe("subagentGroupSummary", () => {10describe("subagentGroupSummary", () => {
6 it.each(["pending", "running", "waiting"] as const)(11 it.each(["pending", "running", "waiting"] as const)(
...@@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => {...@@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => {
36 });41 });
37 });42 });
38});43});
44
45describe("resolveSubagentMetadata", () => {
46 it("resolves provider aliases to catalog names, including custom models", () => {
47 expect(
48 resolveSubagentMetadata({
49 model: "claude-haiku-4-5-20251001",
50 provider: {
51 driver: ProviderDriverKind.make("claudeAgent"),
52 models: [
53 {
54 slug: "claude-haiku-4-5",
55 name: "Claude Haiku 4.5",
56 shortName: "Haiku 4.5",
57 aliases: ["claude-haiku-4-5-20251001"],
58 isCustom: false,
59 capabilities: null,
60 },
61 ],
62 },
63 }).modelLabel,
64 ).toBe("Haiku 4.5");
65 expect(
66 resolveSubagentMetadata({
67 model: "my-model",
68 provider: {
69 driver: ProviderDriverKind.make("acpRegistry"),
70 models: [
71 {
72 slug: "my-model",
73 name: "Cloud+ / My custom model",
74 subProvider: "Cloud+",
75 isCustom: true,
76 capabilities: null,
77 },
78 ],
79 },
80 }).modelLabel,
81 ).toBe("My custom model");
82 });
83
84 it("keeps unknown model identities and does not invent an unreported model", () => {
85 expect(resolveSubagentMetadata({ model: " custom/model " }).modelLabel).toBe("custom/model");
86 expect(
87 resolveSubagentMetadata({
88 model: null,
89 provider: { driver: ProviderDriverKind.make("codex"), models: [] },
90 }).modelLabel,
91 ).toBe("Not reported");
92 expect(resolveSubagentMetadata({ model: " " }).modelLabel).toBe("Not reported");
93 });
94
95 const parentThread = { projectId: ProjectId.make("parent"), worktreePath: null };
96 const parentProject = { workspaceRoot: "/repo" };
97
98 it("shows another project and its branch when the child has a different workspace", () => {
99 expect(
100 resolveSubagentMetadata({
101 model: null,
102 parentThread,
103 parentProject,
104 childThread: { branch: "fix/agents", worktreePath: "/worktrees/agents" },
105 childProject: {
106 id: ProjectId.make("child"),
107 title: "Other project",
108 workspaceRoot: "/other",
109 },
110 }).workspace,
111 ).toEqual([
112 { label: "Project", value: "Other project" },
113 { label: "Branch", value: "fix/agents" },
114 ]);
115 });
116
117 it("labels a detached worktree or another project workspace without a branch", () => {
118 expect(
119 resolveSubagentMetadata({
120 model: null,
121 parentThread,
122 parentProject,
123 childThread: { branch: null, worktreePath: "/worktrees/agents" },
124 }).workspace,
125 ).toEqual([{ label: "Worktree", value: "agents" }]);
126 expect(
127 resolveSubagentMetadata({
128 model: null,
129 parentThread,
130 parentProject,
131 childProject: {
132 id: parentThread.projectId,
133 title: "Same project",
134 workspaceRoot: "/other",
135 },
136 }).workspace,
137 ).toEqual([{ label: "Workspace", value: "other" }]);
138 });
139
140 it("hides redundant workspace metadata and tolerates unavailable child shells", () => {
141 expect(
142 resolveSubagentMetadata({
143 model: null,
144 parentThread,
145 parentProject,
146 childThread: { branch: "main", worktreePath: "/repo" },
147 childProject: { id: parentThread.projectId, title: "Same project", workspaceRoot: "/repo" },
148 }).workspace,
149 ).toEqual([]);
150 expect(resolveSubagentMetadata({ model: null, parentThread, parentProject }).workspace).toEqual(
151 [],
152 );
153 });
154});
155
156describe("subagentDetailPreview", () => {
157 it("prefers progress for live work and results for settled work", () => {
158 const details = { progress: "Reading files", result: "Found two\n problems" };
159 expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files");
160 expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems");
161 expect(
162 subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }),
163 ).toBe("Last progress");
164 expect(subagentDetailPreview({ status: "pending" })).toBeNull();
165 });
166});
packages/client-runtime/src/state/subagentDisplay.ts+87-1
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts";1import type {
2 OrchestrationV2TurnItemStatus,
3 OrchestrationV2ThreadShell,
4 OrchestrationProjectShell,
5 ServerProvider,
6} from "@t3tools/contracts";
7import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
8import { fileBasename } from "../markdownLinks.ts";
9import { isTerminalSubagentStatus } from "./subagentRuntime.ts";
210
3/** Summarizes one adjacent group, without changing its member identities or order. */11/** Summarizes one adjacent group, without changing its member identities or order. */
4export function subagentGroupSummary(12export function subagentGroupSummary(
...@@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string {...@@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string {
44 const name = path[1]!.replace(/[_\s]+/gu, " ").trim();52 const name = path[1]!.replace(/[_\s]+/gu, " ").trim();
45 return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle;53 return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle;
46}54}
55
56/** Match desktop's model resolution and show only changes from the parent's workspace. */
57export function resolveSubagentMetadata(input: {
58 readonly model: string | null;
59 readonly provider?: Pick<ServerProvider, "driver" | "models"> | null | undefined;
60 readonly parentThread?:
61 | Pick<OrchestrationV2ThreadShell, "projectId" | "worktreePath">
62 | null
63 | undefined;
64 readonly childThread?:
65 | Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath">
66 | null
67 | undefined;
68 readonly parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | null | undefined;
69 readonly childProject?:
70 | Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot">
71 | null
72 | undefined;
73}) {
74 const model = input.model?.trim();
75 const slug = input.provider
76 ? resolveSelectableModel(input.provider.driver, model, input.provider.models)
77 : model;
78 const catalogModel = input.provider?.models.find((candidate) => candidate.slug === slug);
79 const reportedLabel = catalogModel
80 ? catalogModel.shortName || catalogModel.name
81 : model
82 ? formatModelSlugName(model)
83 : "Not reported";
84 const qualifier = catalogModel?.subProvider?.trim();
85 const modelLabel = qualifier
86 ? reportedLabel
87 .replace(
88 new RegExp(
89 `^${qualifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[.:/-]\\s*|\\s+)`,
90 "iu",
91 ),
92 "",
93 )
94 .trim() || reportedLabel
95 : reportedLabel;
96 const parentWorkspace = input.parentThread?.worktreePath ?? input.parentProject?.workspaceRoot;
97 const childWorkspace = input.childThread?.worktreePath ?? input.childProject?.workspaceRoot;
98 const workspace = [
99 ...(input.parentThread &&
100 input.childProject &&
101 input.childProject.id !== input.parentThread.projectId
102 ? [{ label: "Project", value: input.childProject.title }]
103 : []),
104 ...(parentWorkspace && childWorkspace && parentWorkspace !== childWorkspace
105 ? [
106 {
107 label: input.childThread?.branch
108 ? "Branch"
109 : input.childThread?.worktreePath
110 ? "Worktree"
111 : "Workspace",
112 value: input.childThread?.branch ?? fileBasename(childWorkspace),
113 },
114 ]
115 : []),
116 ];
117 return { modelLabel, workspace };
118}
119
120/** Live work leads with progress; settled work leads with its result. */
121export function subagentDetailPreview(input: {
122 readonly status: OrchestrationV2TurnItemStatus;
123 readonly result?: string | null | undefined;
124 readonly progress?: string | null | undefined;
125}): string | null {
126 const result = input.result?.trim();
127 const progress = input.progress?.trim();
128 const detail =
129 (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || "";
130 const compact = detail.replace(/\s+/gu, " ");
131 return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null;
132}