From 65731f986ba2175063b0182deb6a143f8f323b74 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 3 Oct 2026 12:45:58 -0400 Subject: [PATCH] fix(mobile): show complete subagent details (#15189) --- .../src/features/threads/SubagentRow.tsx | 154 ++++++++++++++++++ .../features/threads/SubagentStatusDot.tsx | 10 +- .../features/threads/ThreadAgentsSheet.tsx | 47 +++--- .../threads/thread-subagent-group.tsx | 76 ++------- .../threads/threadAgentsPresentation.test.ts | 10 ++ .../threads/threadAgentsPresentation.ts | 14 +- .../chat/SubagentTooltipContent.tsx | 50 +----- .../src/state/subagentDisplay.test.ts | 130 ++++++++++++++- .../src/state/subagentDisplay.ts | 88 +++++++++- 9 files changed, 433 insertions(+), 146 deletions(-) create mode 100644 apps/mobile/src/features/threads/SubagentRow.tsx diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bef52c4b7c756ea3e3f0ff888cc73e4831044e88 --- /dev/null +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -0,0 +1,154 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; +import { subagentCardDetail } from "./subagent-card-presentation"; +import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; +import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; + +type SubagentRowSubagent = Pick< + OrchestrationV2Subagent, + | "threadId" + | "childThreadId" + | "model" + | "driver" + | "providerInstanceId" + | "title" + | "prompt" + | "status" + | "progress" + | "result" +>; + +/** + * One agent as shown in the Agents sheet and transcript cards: status, model, + * workspace changes, and recent progress or result. Callers own the press + * target and pass their own elapsed timer so ticking stays isolated. + */ +export function SubagentRow(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; + readonly elapsed: ReactNode; +}) { + const presentation = resolveSubagentRowPresentation(props.subagent); + const detail = subagentCardDetail(presentation.detail); + return ( + + + + + + + + + {presentation.title} + + + · + + + {presentation.statusLabel} + + + {props.elapsed} + {presentation.canOpenThread ? ( + + ) : null} + + + {detail ? ( + + {detail} + + ) : null} + + + ); +} + +/** Uses shell data already held by the client, without loading child transcripts. */ +function SubagentMetadata(props: { + readonly environmentId: EnvironmentId; + readonly subagent: SubagentRowSubagent; +}) { + const { environmentId, subagent } = props; + const config = useEnvironmentServerConfig(environmentId); + const provider = config?.providers.find( + (candidate) => candidate.instanceId === subagent.providerInstanceId, + ); + const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; + const child = useThreadShell( + subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), + )?.source; + const parentProject = useProject( + parent ? scopeProjectRef(environmentId, parent.projectId) : null, + ); + const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null); + const { modelLabel, workspace } = resolveSubagentMetadata({ + model: subagent.model, + provider, + parentThread: parent, + childThread: child, + parentProject, + childProject, + }); + return ( + + + + {provider?.displayName ? `${provider.displayName} · ` : ""} + {modelLabel} + + {workspace.map(({ label, value }) => ( + // The row reads this label in place of the icon. collapsable keeps the + // view (and label) from being flattened away without making it a + // separate accessibility stop. + + · + + + {value} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/SubagentStatusDot.tsx b/apps/mobile/src/features/threads/SubagentStatusDot.tsx index a08355effb505fc2147baf0202eaf60bbe7f29e7..348f6e534b6c71c23ca2803dbad17ea5e09abb42 100644 --- a/apps/mobile/src/features/threads/SubagentStatusDot.tsx +++ b/apps/mobile/src/features/threads/SubagentStatusDot.tsx @@ -10,19 +10,25 @@ const TONE_CLASS = { stopped: "bg-foreground-muted", } as const satisfies Record; +export const SUBAGENT_TONE_TEXT_CLASS = { + working: "text-adaptive-sky-600-400", + completed: "text-adaptive-emerald-600-400", + failed: "text-adaptive-rose-600-400", + stopped: "text-foreground-muted", +} as const satisfies Record; + export function SubagentStatusDot({ tone, placement = "inline", }: { readonly tone: SubagentRowTone; - readonly placement?: "inline" | "provider" | "sheet"; + readonly placement?: "inline" | "sheet"; }) { return ( diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 7d7c825aaa085100a9e237ab624df3f2302fa87d..1d082515172d3966920a081647aa5e041d2e2724 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; -import { SymbolView } from "../../components/AppSymbol"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { environmentThreadDetails } from "../../state/threads"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; - -import { SubagentStatusDot } from "./SubagentStatusDot"; +import { SubagentRow } from "./SubagentRow"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { @@ -121,32 +119,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { } function AgentRow(props: { + readonly environmentId: EnvironmentId; readonly subagent: OrchestrationV2Subagent; readonly tickSeconds: boolean; readonly onOpen: (childThreadId: ThreadId) => void; }) { const { subagent } = props; - const presentation = resolveSubagentRowPresentation(subagent); const childThreadId = subagent.childThreadId; - const elapsed = useSubagentElapsed(subagent, props.tickSeconds); const row = ( - - - - - {presentation.title} - - - {presentation.detail ?? presentation.statusLabel} - - - {elapsed === null ? null : ( - {elapsed} - )} - {presentation.canOpenThread ? ( - - ) : null} + + } + /> ); @@ -154,7 +141,6 @@ function AgentRow(props: { return ( {row} @@ -165,7 +151,6 @@ function AgentRow(props: { return ( props.onOpen(childThreadId)} className="active:opacity-70" @@ -175,9 +160,19 @@ function AgentRow(props: { ); } +function AgentElapsed(props: { + readonly subagent: OrchestrationV2Subagent; + readonly tickSeconds: boolean; +}) { + const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds); + return elapsed === null ? null : ( + {elapsed} + ); +} + /** - * Elapsed time for one agent. Only a roster with live work subscribes to the - * shared second tick, so a settled sheet never repaints. + * Elapsed time for one agent. Only live work ticks, inside AgentElapsed, + * so the timer never repaints the metadata or a settled sheet. */ function useSubagentElapsed( subagent: Pick, diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 0c51c6195b7512ade195cf9f8d72aad4c41d18cc..c0f1adf4736e731d69b09c5f8005b7243baae4df 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -2,10 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display"; -import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, OrchestrationV2Subagent, @@ -21,9 +18,8 @@ import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; -import { SubagentStatusDot } from "./SubagentStatusDot"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; +import { SubagentRow } from "./SubagentRow"; import { WorkLogBlock } from "./work-log-layout"; type SubagentItem = Extract; @@ -47,27 +43,20 @@ function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray{elapsed} + {elapsed} ) : null; } function SubagentAvatar(props: { readonly item: SubagentItem; readonly iconUrl?: string | null | undefined; - readonly status?: OrchestrationV2Subagent["status"]; }) { return ( - {props.status ? ( - - ) : null} ); } @@ -99,6 +88,7 @@ export function ThreadSubagentGroup(props: { completedAt: live?.completedAt ?? item.completedAt, result: live?.result ?? item.result, progress: live?.progress ?? item.progress, + model: live?.model ?? null, }; }); const grouped = agents.length > 1; @@ -157,19 +147,12 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - const presentation = resolveSubagentRowPresentation(agent); - const detail = subagentCardDetail( - isTerminalSubagentStatus(agent.status) - ? agent.result?.trim() || agent.progress || null - : agent.progress?.trim() || agent.result, - ); const threadId = agent.childThreadId; return ( - } /> - - - - {presentation.title} - - {detail && agent.status !== "completed" ? ( - - {presentation.statusLabel} - - ) : null} - - - {detail ?? presentation.statusLabel} - - - - {threadId !== null ? ( - - ) : null} ); })} diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts index bf095f54187358a8e1cac9be5e3199ee284c4ba4..65c12910cea7dd75a6bb68fa4fa8e91fa8b1f0f2 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts @@ -65,4 +65,14 @@ describe("resolveSubagentRowPresentation", () => { expect(row.detail).toBeNull(); expect(row.statusLabel).toBe("Working"); }); + + it("bounds long result previews without dropping the agent's status", () => { + const row = resolveSubagentRowPresentation({ + ...base, + status: "failed", + result: "x".repeat(400), + }); + expect(row.detail).toBe(`${"x".repeat(280)}…`); + expect(row.statusLabel).toBe("Failed"); + }); }); diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d1b85b8964706ef380fc6e44f879ba7d08949849..9605891143b4ae46c3b054418f0f628880649969 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -1,8 +1,8 @@ -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { - isActiveSubagentStatus, - isTerminalSubagentStatus, -} from "@t3tools/client-runtime/state/subagentRuntime"; + formatSubagentDisplayTitle, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; +import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; import type { OrchestrationV2Subagent } from "@t3tools/contracts"; const PROMPT_TITLE_LIMIT = 80; @@ -64,13 +64,9 @@ export function resolveSubagentRowPresentation( >, ): SubagentRowPresentation { const live = isActiveSubagentStatus(subagent.status); - const progress = subagent.progress?.trim() ?? ""; - const result = subagent.result?.trim() ?? ""; - const settled = isTerminalSubagentStatus(subagent.status); - const detail = settled ? result || progress : progress || result; return { title: rowTitle(subagent), - detail: detail.length > 0 ? detail.replace(/\s+/gu, " ") : null, + detail: subagentDetailPreview(subagent), statusLabel: rowStatusLabel(subagent.status), tone: rowTone(subagent.status), live, diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 49c382ba639fb5152d8cb2c33d6caa26bfd6527f..d638736759a4368ed416e625f9e1b9023720ec4a 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -1,12 +1,14 @@ import type { OrchestrationV2ThreadShell, + OrchestrationV2TurnItemStatus, OrchestrationProjectShell, ServerProvider, ProviderDriverKind, } from "@t3tools/contracts"; -import { fileBasename } from "@t3tools/client-runtime/markdown-links"; -import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; -import { getTriggerDisplayModelName } from "./providerIconUtils"; +import { + resolveSubagentMetadata, + subagentDetailPreview, +} from "@t3tools/client-runtime/state/subagent-display"; import type { ReactNode } from "react"; import { BotIcon, @@ -33,48 +35,12 @@ export function SubagentTooltipContent(props: { childThread?: Pick | undefined; parentProject?: Pick | undefined; childProject?: Pick | undefined; - status: string; + status: OrchestrationV2TurnItemStatus; result?: string | null | undefined; progress?: string | null | undefined; }) { - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); - const modelLabel = providerModel - ? getTriggerDisplayModelName(providerModel) - : model - ? formatModelSlugName(model) - : "Not reported"; - const currentWorkspace = props.parentThread?.worktreePath ?? props.parentProject?.workspaceRoot; - const childWorkspace = props.childThread?.worktreePath ?? props.childProject?.workspaceRoot; - const metadata = [ - ...(props.parentThread && - props.childProject && - props.childProject.id !== props.parentThread.projectId - ? [{ label: "Project", value: props.childProject.title }] - : []), - ...(currentWorkspace && childWorkspace && currentWorkspace !== childWorkspace - ? [ - { - label: props.childThread?.branch - ? "Branch" - : props.childThread?.worktreePath - ? "Worktree" - : "Workspace", - value: props.childThread?.branch ?? fileBasename(childWorkspace), - }, - ] - : []), - ]; - const settled = ["completed", "failed", "cancelled", "interrupted"].includes(props.status); - const result = props.result?.trim(); - const progress = props.progress?.trim(); - const detail = (settled ? result || progress : progress || result) || ""; - const compactDetail = detail.trim().replace(/\s+/g, " "); - const preview = - compactDetail.length > 280 ? `${compactDetail.slice(0, 280).trimEnd()}…` : compactDetail; + const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); + const preview = subagentDetailPreview(props); const driver = props.provider?.driver ?? props.driver; const working = ["running", "in_progress", "pending", "waiting"].includes(props.status); const failed = ["failed", "error"].includes(props.status); diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts index 11184d369469dbe3aca6a5d166003b46aaba492b..3485cd4f2da2dbc0c30a2937ae605a298166bc25 100644 --- a/packages/client-runtime/src/state/subagentDisplay.test.ts +++ b/packages/client-runtime/src/state/subagentDisplay.test.ts @@ -1,6 +1,11 @@ +import { ProjectId, ProviderDriverKind } from "@t3tools/contracts"; import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { subagentGroupSummary } from "./subagentDisplay.js"; +import { + subagentGroupSummary, + resolveSubagentMetadata, + subagentDetailPreview, +} from "./subagentDisplay.js"; describe("subagentGroupSummary", () => { it.each(["pending", "running", "waiting"] as const)( @@ -36,3 +41,126 @@ describe("subagentGroupSummary", () => { }); }); }); + +describe("resolveSubagentMetadata", () => { + it("resolves provider aliases to catalog names, including custom models", () => { + expect( + resolveSubagentMetadata({ + model: "claude-haiku-4-5-20251001", + provider: { + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + shortName: "Haiku 4.5", + aliases: ["claude-haiku-4-5-20251001"], + isCustom: false, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("Haiku 4.5"); + expect( + resolveSubagentMetadata({ + model: "my-model", + provider: { + driver: ProviderDriverKind.make("acpRegistry"), + models: [ + { + slug: "my-model", + name: "Cloud+ / My custom model", + subProvider: "Cloud+", + isCustom: true, + capabilities: null, + }, + ], + }, + }).modelLabel, + ).toBe("My custom model"); + }); + + it("keeps unknown model identities and does not invent an unreported model", () => { + expect(resolveSubagentMetadata({ model: " custom/model " }).modelLabel).toBe("custom/model"); + expect( + resolveSubagentMetadata({ + model: null, + provider: { driver: ProviderDriverKind.make("codex"), models: [] }, + }).modelLabel, + ).toBe("Not reported"); + expect(resolveSubagentMetadata({ model: " " }).modelLabel).toBe("Not reported"); + }); + + const parentThread = { projectId: ProjectId.make("parent"), worktreePath: null }; + const parentProject = { workspaceRoot: "/repo" }; + + it("shows another project and its branch when the child has a different workspace", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "fix/agents", worktreePath: "/worktrees/agents" }, + childProject: { + id: ProjectId.make("child"), + title: "Other project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([ + { label: "Project", value: "Other project" }, + { label: "Branch", value: "fix/agents" }, + ]); + }); + + it("labels a detached worktree or another project workspace without a branch", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: null, worktreePath: "/worktrees/agents" }, + }).workspace, + ).toEqual([{ label: "Worktree", value: "agents" }]); + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childProject: { + id: parentThread.projectId, + title: "Same project", + workspaceRoot: "/other", + }, + }).workspace, + ).toEqual([{ label: "Workspace", value: "other" }]); + }); + + it("hides redundant workspace metadata and tolerates unavailable child shells", () => { + expect( + resolveSubagentMetadata({ + model: null, + parentThread, + parentProject, + childThread: { branch: "main", worktreePath: "/repo" }, + childProject: { id: parentThread.projectId, title: "Same project", workspaceRoot: "/repo" }, + }).workspace, + ).toEqual([]); + expect(resolveSubagentMetadata({ model: null, parentThread, parentProject }).workspace).toEqual( + [], + ); + }); +}); + +describe("subagentDetailPreview", () => { + it("prefers progress for live work and results for settled work", () => { + const details = { progress: "Reading files", result: "Found two\n problems" }; + expect(subagentDetailPreview({ ...details, status: "running" })).toBe("Reading files"); + expect(subagentDetailPreview({ ...details, status: "completed" })).toBe("Found two problems"); + expect( + subagentDetailPreview({ status: "failed", progress: "Last progress", result: " " }), + ).toBe("Last progress"); + expect(subagentDetailPreview({ status: "pending" })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 4d1d2c5cd15d814097e8fe7ef0e187dff29809bb..0a456e7a2ddc68664a4bc521ee02f4b8d1941de5 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,4 +1,12 @@ -import type { OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; +import type { + OrchestrationV2TurnItemStatus, + OrchestrationV2ThreadShell, + OrchestrationProjectShell, + ServerProvider, +} from "@t3tools/contracts"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { fileBasename } from "../markdownLinks.ts"; +import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; /** Summarizes one adjacent group, without changing its member identities or order. */ export function subagentGroupSummary( @@ -44,3 +52,81 @@ export function formatSubagentDisplayTitle(title: string): string { const name = path[1]!.replace(/[_\s]+/gu, " ").trim(); return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle; } + +/** Match desktop's model resolution and show only changes from the parent's workspace. */ +export function resolveSubagentMetadata(input: { + readonly model: string | null; + readonly provider?: Pick | null | undefined; + readonly parentThread?: + | Pick + | null + | undefined; + readonly childThread?: + | Pick + | null + | undefined; + readonly parentProject?: Pick | null | undefined; + readonly childProject?: + | Pick + | null + | undefined; +}) { + const model = input.model?.trim(); + const slug = input.provider + ? resolveSelectableModel(input.provider.driver, model, input.provider.models) + : model; + const catalogModel = input.provider?.models.find((candidate) => candidate.slug === slug); + const reportedLabel = catalogModel + ? catalogModel.shortName || catalogModel.name + : model + ? formatModelSlugName(model) + : "Not reported"; + const qualifier = catalogModel?.subProvider?.trim(); + const modelLabel = qualifier + ? reportedLabel + .replace( + new RegExp( + `^${qualifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s*[.:/-]\\s*|\\s+)`, + "iu", + ), + "", + ) + .trim() || reportedLabel + : reportedLabel; + const parentWorkspace = input.parentThread?.worktreePath ?? input.parentProject?.workspaceRoot; + const childWorkspace = input.childThread?.worktreePath ?? input.childProject?.workspaceRoot; + const workspace = [ + ...(input.parentThread && + input.childProject && + input.childProject.id !== input.parentThread.projectId + ? [{ label: "Project", value: input.childProject.title }] + : []), + ...(parentWorkspace && childWorkspace && parentWorkspace !== childWorkspace + ? [ + { + label: input.childThread?.branch + ? "Branch" + : input.childThread?.worktreePath + ? "Worktree" + : "Workspace", + value: input.childThread?.branch ?? fileBasename(childWorkspace), + }, + ] + : []), + ]; + return { modelLabel, workspace }; +} + +/** Live work leads with progress; settled work leads with its result. */ +export function subagentDetailPreview(input: { + readonly status: OrchestrationV2TurnItemStatus; + readonly result?: string | null | undefined; + readonly progress?: string | null | undefined; +}): string | null { + const result = input.result?.trim(); + const progress = input.progress?.trim(); + const detail = + (isTerminalSubagentStatus(input.status) ? result || progress : progress || result) || ""; + const compact = detail.replace(/\s+/gu, " "); + return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null; +} -- 2.54.0