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 = {
1010 stopped: "bg-foreground-muted",
1111} 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
1320export function SubagentStatusDot({
1421 tone,
1522 placement = "inline",
1623}: {
1724 readonly tone: SubagentRowTone;
18 readonly placement?: "inline" | "provider" | "sheet";
25 readonly placement?: "inline" | "sheet";
1926}) {
2027 return (
2128 <View
2229 className={cn(
2330 "shrink-0 rounded-full",
2431 placement === "sheet" ? "h-2 w-2" : "h-1.5 w-1.5",
25 placement === "provider" && "absolute bottom-0.5 right-0.5",
2632 TONE_CLASS[tone],
2733 )}
2834 />
apps/mobile/src/features/threads/ThreadAgentsSheet.tsx+21-26
......@@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1717
1818import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
1919import { AppText as Text } from "../../components/AppText";
20import { SymbolView } from "../../components/AppSymbol";
2120import { useUniwindTheme } from "../../lib/useUniwindTheme";
2221import { environmentThreadDetails } from "../../state/threads";
2322import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
24import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
25
26import { SubagentStatusDot } from "./SubagentStatusDot";
23import { SubagentRow } from "./SubagentRow";
2724
2825const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
2926
......@@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
7269 <AgentRow
7370 key={subagent.id}
7471 subagent={subagent}
72 environmentId={target.environmentId}
7573 tickSeconds={hasLiveAgent}
7674 onOpen={openChildThread}
7775 />
......@@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
121119}
122120
123121function AgentRow(props: {
122 readonly environmentId: EnvironmentId;
124123 readonly subagent: OrchestrationV2Subagent;
125124 readonly tickSeconds: boolean;
126125 readonly onOpen: (childThreadId: ThreadId) => void;
127126}) {
128127 const { subagent } = props;
129 const presentation = resolveSubagentRowPresentation(subagent);
130128 const childThreadId = subagent.childThreadId;
131 const elapsed = useSubagentElapsed(subagent, props.tickSeconds);
132129
133130 const row = (
134 <View className="min-h-14 flex-row items-center gap-3 border-b border-border py-3">
135 <SubagentStatusDot tone={presentation.tone} placement="sheet" />
136 <View className="min-w-0 flex-1 gap-0.5">
137 <Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>
138 {presentation.title}
139 </Text>
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}
131 <View className="border-b border-border py-3.5">
132 <SubagentRow
133 environmentId={props.environmentId}
134 subagent={subagent}
135 elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
136 />
150137 </View>
151138 );
152139
......@@ -154,7 +141,6 @@ function AgentRow(props: {
154141 return (
155142 <View
156143 accessible
157 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
158144 accessibilityHint="Provider-managed agent. Its work appears in the transcript."
159145 >
160146 {row}
......@@ -165,7 +151,6 @@ function AgentRow(props: {
165151 return (
166152 <Pressable
167153 accessibilityRole="link"
168 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
169154 accessibilityHint="Opens this agent's thread"
170155 onPress={() => props.onOpen(childThreadId)}
171156 className="active:opacity-70"
......@@ -175,9 +160,19 @@ function AgentRow(props: {
175160 );
176161}
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
178173/**
179 * Elapsed time for one agent. Only a roster with live work subscribes to the
180 * shared second tick, so a settled sheet never repaints.
174 * Elapsed time for one agent. Only live work ticks, inside AgentElapsed,
175 * so the timer never repaints the metadata or a settled sheet.
181176 */
182177function useSubagentElapsed(
183178 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";
22import { useIsFocused, useNavigation } from "@react-navigation/native";
33import { scopeThreadRef } from "@t3tools/client-runtime/environment";
44import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
5import {
6 isActiveSubagentStatus,
7 isTerminalSubagentStatus,
8} from "@t3tools/client-runtime/state/subagentRuntime";
5import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
96import type {
107 EnvironmentId,
118 OrchestrationV2Subagent,
......@@ -21,9 +18,8 @@ import { cn } from "../../lib/cn";
2118import type { ThreadFeedActivity } from "../../lib/threadActivity";
2219import { serverEnvironment } from "../../state/server";
2320import { environmentThreadDetails } from "../../state/threads";
24import { SubagentStatusDot } from "./SubagentStatusDot";
25import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";
26import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
21import { subagentCardElapsed } from "./subagent-card-presentation";
22import { SubagentRow } from "./SubagentRow";
2723import { WorkLogBlock } from "./work-log-layout";
2824
2925type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
......@@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin
4743 }, [appActive, focused, live]);
4844 const elapsed = subagentCardElapsed(agents, nowMs);
4945 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>
5147 ) : null;
5248}
5349
5450function SubagentAvatar(props: {
5551 readonly item: SubagentItem;
5652 readonly iconUrl?: string | null | undefined;
57 readonly status?: OrchestrationV2Subagent["status"];
5853}) {
5954 return (
6055 <View
6156 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"
6358 >
6459 <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}
7160 </View>
7261 );
7362}
......@@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: {
9988 completedAt: live?.completedAt ?? item.completedAt,
10089 result: live?.result ?? item.result,
10190 progress: live?.progress ?? item.progress,
91 model: live?.model ?? null,
10292 };
10393 });
10494 const grouped = agents.length > 1;
......@@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: {
157147 {!grouped || expanded ? (
158148 <View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
159149 {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 );
166150 const threadId = agent.childThreadId;
167151 return (
168152 <Pressable
169153 key={agent.item.id}
170154 accessible
171155 accessibilityRole={threadId === null ? undefined : "link"}
172 accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}${detail ? `, ${detail}` : ""}`}
173156 accessibilityHint={
174157 threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
175158 }
......@@ -181,50 +164,13 @@ export function ThreadSubagentGroup(props: {
181164 threadId: String(threadId),
182165 });
183166 }}
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"
185168 >
186 <SubagentAvatar
187 item={agent.item}
188 iconUrl={iconUrl(agent.item)}
189 status={agent.status}
169 <SubagentRow
170 environmentId={props.environmentId}
171 subagent={agent}
172 elapsed={<SubagentElapsed agents={[agent]} />}
190173 />
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}
228174 </Pressable>
229175 );
230176 })}
apps/mobile/src/features/threads/threadAgentsPresentation.test.ts+10
......@@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => {
6565 expect(row.detail).toBeNull();
6666 expect(row.statusLabel).toBe("Working");
6767 });
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 });
6878});
apps/mobile/src/features/threads/threadAgentsPresentation.ts+5-9
......@@ -1,8 +1,8 @@
1import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
21import {
3 isActiveSubagentStatus,
4 isTerminalSubagentStatus,
5} from "@t3tools/client-runtime/state/subagentRuntime";
2 formatSubagentDisplayTitle,
3 subagentDetailPreview,
4} from "@t3tools/client-runtime/state/subagent-display";
5import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
66import type { OrchestrationV2Subagent } from "@t3tools/contracts";
77
88const PROMPT_TITLE_LIMIT = 80;
......@@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation(
6464 >,
6565): SubagentRowPresentation {
6666 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;
7167 return {
7268 title: rowTitle(subagent),
73 detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null,
69 detail: subagentDetailPreview(subagent),
7470 statusLabel: rowStatusLabel(subagent.status),
7571 tone: rowTone(subagent.status),
7672 live,
apps/web/src/components/chat/SubagentTooltipContent.tsx+8-42
......@@ -1,12 +1,14 @@
11import type {
22 OrchestrationV2ThreadShell,
3 OrchestrationV2TurnItemStatus,
34 OrchestrationProjectShell,
45 ServerProvider,
56 ProviderDriverKind,
67} from "@t3tools/contracts";
7import { fileBasename } from "@t3tools/client-runtime/markdown-links";
8import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
9import { getTriggerDisplayModelName } from "./providerIconUtils";
8import {
9 resolveSubagentMetadata,
10 subagentDetailPreview,
11} from "@t3tools/client-runtime/state/subagent-display";
1012import type { ReactNode } from "react";
1113import {
1214 BotIcon,
......@@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: {
3335 childThread?: Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath"> | undefined;
3436 parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | undefined;
3537 childProject?: Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot"> | undefined;
36 status: string;
38 status: OrchestrationV2TurnItemStatus;
3739 result?: string | null | undefined;
3840 progress?: string | null | undefined;
3941}) {
40 const model = props.model?.trim();
41 const modelSlug = props.provider
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;
42 const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props);
43 const preview = subagentDetailPreview(props);
7844 const driver = props.provider?.driver ?? props.driver;
7945 const working = ["running", "in_progress", "pending", "waiting"].includes(props.status);
8046 const failed = ["failed", "error"].includes(props.status);
packages/client-runtime/src/state/subagentDisplay.test.ts+129-1
......@@ -1,6 +1,11 @@
1import { ProjectId, ProviderDriverKind } from "@t3tools/contracts";
12import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts";
23import { describe, expect, it } from "vite-plus/test";
3import { subagentGroupSummary } from "./subagentDisplay.js";
4import {
5 subagentGroupSummary,
6 resolveSubagentMetadata,
7 subagentDetailPreview,
8} from "./subagentDisplay.js";
49
510describe("subagentGroupSummary", () => {
611 it.each(["pending", "running", "waiting"] as const)(
......@@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => {
3641 });
3742 });
3843});
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 @@
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
311/** Summarizes one adjacent group, without changing its member identities or order. */
412export function subagentGroupSummary(
......@@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string {
4452 const name = path[1]!.replace(/[_\s]+/gu, " ").trim();
4553 return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle;
4654}
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}