| author | |
| committer | |
| log | 65731f986ba2175063b0182deb6a143f8f323b74 |
| tree | b2c8a8544767f790f66987ce096c187b62ba5787 |
| parent | aad732901e4b7d485574eaef5a9c1fb388c4291a |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 433 insertions(+), 146 deletions(-)
apps/mobile/src/features/threads/SubagentRow.tsx created+154| ... | ... | @@ -0,0 +1,154 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; | |
| 3 | import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; | |
| 4 | import type { ReactNode } from "react"; | |
| 5 | import { View } from "react-native"; | |
| 6 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | |
| 9 | import { ProviderIcon } from "../../components/ProviderIcon"; | |
| 10 | import { cn } from "../../lib/cn"; | |
| 11 | import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; | |
| 12 | import { subagentCardDetail } from "./subagent-card-presentation"; | |
| 13 | import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; | |
| 14 | import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; | |
| 15 | ||
| 16 | type 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 | */ | |
| 35 | export 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. */ | |
| 95 | function 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 | 10 | stopped: "bg-foreground-muted", |
| 11 | 11 | } as const satisfies Record<SubagentRowTone, string>; |
| 12 | 12 | |
| 13 | export 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 | ||
| 13 | 20 | export function SubagentStatusDot({ |
| 14 | 21 | tone, |
| 15 | 22 | placement = "inline", |
| 16 | 23 | }: { |
| 17 | 24 | readonly tone: SubagentRowTone; |
| 18 | readonly placement?: "inline" | "provider" | "sheet"; | |
| 25 | readonly placement?: "inline" | "sheet"; | |
| 19 | 26 | }) { |
| 20 | 27 | return ( |
| 21 | 28 | <View |
| 22 | 29 | className={cn( |
| 23 | 30 | "shrink-0 rounded-full", |
| 24 | 31 | placement === "sheet" ? "h-2 w-2" : "h-1.5 w-1.5", |
| 25 | placement === "provider" && "absolute bottom-0.5 right-0.5", | |
| 26 | 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 | 17 | |
| 18 | 18 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; |
| 19 | 19 | import { AppText as Text } from "../../components/AppText"; |
| 20 | import { SymbolView } from "../../components/AppSymbol"; | |
| 21 | 20 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 22 | 21 | import { environmentThreadDetails } from "../../state/threads"; |
| 23 | 22 | import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; |
| 24 | import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; | |
| 25 | ||
| 26 | import { SubagentStatusDot } from "./SubagentStatusDot"; | |
| 23 | import { SubagentRow } from "./SubagentRow"; | |
| 27 | 24 | |
| 28 | 25 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| 29 | 26 | |
| ... | ... | @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) { |
| 72 | 69 | <AgentRow |
| 73 | 70 | key={subagent.id} |
| 74 | 71 | subagent={subagent} |
| 72 | environmentId={target.environmentId} | |
| 75 | 73 | tickSeconds={hasLiveAgent} |
| 76 | 74 | onOpen={openChildThread} |
| 77 | 75 | /> |
| ... | ... | @@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) { |
| 121 | 119 | } |
| 122 | 120 | |
| 123 | 121 | function AgentRow(props: { |
| 122 | readonly environmentId: EnvironmentId; | |
| 124 | 123 | readonly subagent: OrchestrationV2Subagent; |
| 125 | 124 | readonly tickSeconds: boolean; |
| 126 | 125 | readonly onOpen: (childThreadId: ThreadId) => void; |
| 127 | 126 | }) { |
| 128 | 127 | const { subagent } = props; |
| 129 | const presentation = resolveSubagentRowPresentation(subagent); | |
| 130 | 128 | const childThreadId = subagent.childThreadId; |
| 131 | const elapsed = useSubagentElapsed(subagent, props.tickSeconds); | |
| 132 | 129 | |
| 133 | 130 | 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 | /> | |
| 150 | 137 | </View> |
| 151 | 138 | ); |
| 152 | 139 | |
| ... | ... | @@ -154,7 +141,6 @@ function AgentRow(props: { |
| 154 | 141 | return ( |
| 155 | 142 | <View |
| 156 | 143 | accessible |
| 157 | accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`} | |
| 158 | 144 | accessibilityHint="Provider-managed agent. Its work appears in the transcript." |
| 159 | 145 | > |
| 160 | 146 | {row} |
| ... | ... | @@ -165,7 +151,6 @@ function AgentRow(props: { |
| 165 | 151 | return ( |
| 166 | 152 | <Pressable |
| 167 | 153 | accessibilityRole="link" |
| 168 | accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`} | |
| 169 | 154 | accessibilityHint="Opens this agent's thread" |
| 170 | 155 | onPress={() => props.onOpen(childThreadId)} |
| 171 | 156 | className="active:opacity-70" |
| ... | ... | @@ -175,9 +160,19 @@ function AgentRow(props: { |
| 175 | 160 | ); |
| 176 | 161 | } |
| 177 | 162 | |
| 163 | function 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 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. | |
| 181 | 176 | */ |
| 182 | 177 | function useSubagentElapsed( |
| 183 | 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 | 2 | import { useIsFocused, useNavigation } from "@react-navigation/native"; |
| 3 | 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | 4 | import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; |
| 5 | import { | |
| 6 | isActiveSubagentStatus, | |
| 7 | isTerminalSubagentStatus, | |
| 8 | } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 5 | import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 9 | 6 | import type { |
| 10 | 7 | EnvironmentId, |
| 11 | 8 | OrchestrationV2Subagent, |
| ... | ... | @@ -21,9 +18,8 @@ import { cn } from "../../lib/cn"; |
| 21 | 18 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 22 | 19 | import { serverEnvironment } from "../../state/server"; |
| 23 | 20 | import { environmentThreadDetails } from "../../state/threads"; |
| 24 | import { SubagentStatusDot } from "./SubagentStatusDot"; | |
| 25 | import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; | |
| 26 | import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; | |
| 21 | import { subagentCardElapsed } from "./subagent-card-presentation"; | |
| 22 | import { SubagentRow } from "./SubagentRow"; | |
| 27 | 23 | import { WorkLogBlock } from "./work-log-layout"; |
| 28 | 24 | |
| 29 | 25 | type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>; |
| ... | ... | @@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTimin |
| 47 | 43 | }, [appActive, focused, live]); |
| 48 | 44 | const elapsed = subagentCardElapsed(agents, nowMs); |
| 49 | 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 | 47 | ) : null; |
| 52 | 48 | } |
| 53 | 49 | |
| 54 | 50 | function SubagentAvatar(props: { |
| 55 | 51 | readonly item: SubagentItem; |
| 56 | 52 | readonly iconUrl?: string | null | undefined; |
| 57 | readonly status?: OrchestrationV2Subagent["status"]; | |
| 58 | 53 | }) { |
| 59 | 54 | return ( |
| 60 | 55 | <View |
| 61 | 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 | 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 | 60 | </View> |
| 72 | 61 | ); |
| 73 | 62 | } |
| ... | ... | @@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: { |
| 99 | 88 | completedAt: live?.completedAt ?? item.completedAt, |
| 100 | 89 | result: live?.result ?? item.result, |
| 101 | 90 | progress: live?.progress ?? item.progress, |
| 91 | model: live?.model ?? null, | |
| 102 | 92 | }; |
| 103 | 93 | }); |
| 104 | 94 | const grouped = agents.length > 1; |
| ... | ... | @@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: { |
| 157 | 147 | {!grouped || expanded ? ( |
| 158 | 148 | <View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1"> |
| 159 | 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 | 150 | const threadId = agent.childThreadId; |
| 167 | 151 | return ( |
| 168 | 152 | <Pressable |
| 169 | 153 | key={agent.item.id} |
| 170 | 154 | accessible |
| 171 | 155 | accessibilityRole={threadId === null ? undefined : "link"} |
| 172 | accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}${detail ? `, ${detail}` : ""}`} | |
| 173 | 156 | accessibilityHint={ |
| 174 | 157 | threadId === null ? "Provider-managed agent" : "Opens this agent's thread" |
| 175 | 158 | } |
| ... | ... | @@ -181,50 +164,13 @@ export function ThreadSubagentGroup(props: { |
| 181 | 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 | <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]} />} | |
| 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 | 174 | </Pressable> |
| 229 | 175 | ); |
| 230 | 176 | })} |
apps/mobile/src/features/threads/threadAgentsPresentation.test.ts+10| ... | ... | @@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => { |
| 65 | 65 | expect(row.detail).toBeNull(); |
| 66 | 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 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; | |
| 2 | 1 | import { |
| 3 | isActiveSubagentStatus, | |
| 4 | isTerminalSubagentStatus, | |
| 5 | } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 2 | formatSubagentDisplayTitle, | |
| 3 | subagentDetailPreview, | |
| 4 | } from "@t3tools/client-runtime/state/subagent-display"; | |
| 5 | import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 6 | 6 | import type { OrchestrationV2Subagent } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | const PROMPT_TITLE_LIMIT = 80; |
| ... | ... | @@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation( |
| 64 | 64 | >, |
| 65 | 65 | ): SubagentRowPresentation { |
| 66 | 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 | 67 | return { |
| 72 | 68 | title: rowTitle(subagent), |
| 73 | detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null, | |
| 69 | detail: subagentDetailPreview(subagent), | |
| 74 | 70 | statusLabel: rowStatusLabel(subagent.status), |
| 75 | 71 | tone: rowTone(subagent.status), |
| 76 | 72 | live, |
apps/web/src/components/chat/SubagentTooltipContent.tsx+8-42| ... | ... | @@ -1,12 +1,14 @@ |
| 1 | 1 | import type { |
| 2 | 2 | OrchestrationV2ThreadShell, |
| 3 | OrchestrationV2TurnItemStatus, | |
| 3 | 4 | OrchestrationProjectShell, |
| 4 | 5 | ServerProvider, |
| 5 | 6 | ProviderDriverKind, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | import { fileBasename } from "@t3tools/client-runtime/markdown-links"; | |
| 8 | import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 9 | import { getTriggerDisplayModelName } from "./providerIconUtils"; | |
| 8 | import { | |
| 9 | resolveSubagentMetadata, | |
| 10 | subagentDetailPreview, | |
| 11 | } from "@t3tools/client-runtime/state/subagent-display"; | |
| 10 | 12 | import type { ReactNode } from "react"; |
| 11 | 13 | import { |
| 12 | 14 | BotIcon, |
| ... | ... | @@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: { |
| 33 | 35 | childThread?: Pick<OrchestrationV2ThreadShell, "branch" | "worktreePath"> | undefined; |
| 34 | 36 | parentProject?: Pick<OrchestrationProjectShell, "workspaceRoot"> | undefined; |
| 35 | 37 | childProject?: Pick<OrchestrationProjectShell, "id" | "title" | "workspaceRoot"> | undefined; |
| 36 | status: string; | |
| 38 | status: OrchestrationV2TurnItemStatus; | |
| 37 | 39 | result?: string | null | undefined; |
| 38 | 40 | progress?: string | null | undefined; |
| 39 | 41 | }) { |
| 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); | |
| 78 | 44 | const driver = props.provider?.driver ?? props.driver; |
| 79 | 45 | const working = ["running", "in_progress", "pending", "waiting"].includes(props.status); |
| 80 | 46 | const failed = ["failed", "error"].includes(props.status); |
packages/client-runtime/src/state/subagentDisplay.test.ts+129-1| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | import { ProjectId, ProviderDriverKind } from "@t3tools/contracts"; | |
| 1 | 2 | import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; |
| 2 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { subagentGroupSummary } from "./subagentDisplay.js"; | |
| 4 | import { | |
| 5 | subagentGroupSummary, | |
| 6 | resolveSubagentMetadata, | |
| 7 | subagentDetailPreview, | |
| 8 | } from "./subagentDisplay.js"; | |
| 4 | 9 | |
| 5 | 10 | describe("subagentGroupSummary", () => { |
| 6 | 11 | it.each(["pending", "running", "waiting"] as const)( |
| ... | ... | @@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => { |
| 36 | 41 | }); |
| 37 | 42 | }); |
| 38 | 43 | }); |
| 44 | ||
| 45 | describe("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 | ||
| 156 | describe("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 | import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | OrchestrationV2TurnItemStatus, | |
| 3 | OrchestrationV2ThreadShell, | |
| 4 | OrchestrationProjectShell, | |
| 5 | ServerProvider, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 8 | import { fileBasename } from "../markdownLinks.ts"; | |
| 9 | import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; | |
| 2 | 10 | |
| 3 | 11 | /** Summarizes one adjacent group, without changing its member identities or order. */ |
| 4 | 12 | export function subagentGroupSummary( |
| ... | ... | @@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string { |
| 44 | 52 | const name = path[1]!.replace(/[_\s]+/gu, " ").trim(); |
| 45 | 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. */ | |
| 57 | export 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. */ | |
| 121 | export 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 | } |