From 39ccf4fb56fa36f678beb580884fd950ec26243b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:01:39 +0000 Subject: [PATCH] feat(native-chat): show each subagent's model, current tool and result in the chat (#41766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): show each subagent's model, current tool and result in the roster Subagent roster rows now carry the child's role, model, reasoning effort, the tool it is running and the last thing it said, so a row says what the subagent is doing and, once done, what it found. Codex children take model and effort from the spawn call; Claude children take the model from their own messages. A hover card shows the longer text. * feat(native-chat): name subagent models and roles the way people say them Show Opus 5.5 · High instead of claude-opus-5-5 · high, reusing the model-id label and effort picker copy, and General purpose instead of general-purpose. * fix(native-chat): read Codex subagent model and effort from its started activity Current Codex announces a spawn only through subAgentActivity, which carries the model and reasoning effort the child resolved to. * fix(native-chat): use the hover card's own spacing variant for the subagent preview * fix(native-chat): show a subagent's reply as plain text in its roster row and preview Its last message is markdown; the row now drops backticks, emphasis, list and heading marks and joins lines into one readable line. --- .../MobileNativeChatSubagentGroup.tsx | 55 +++++-- .../claude-child-work-evidence.test.ts | 26 ++++ .../child-work/claude-child-work-evidence.ts | 10 +- .../codex-background-task-frames.ts | 8 +- .../codex-child-work-evidence.ts | 2 + ...dex-structured-child-work-producer.test.ts | 18 +++ .../codex-subagent-activity.ts | 22 ++- .../codex-subagent-executions.ts | 16 +- .../items/codex-collab-agent-tool-call.ts | 5 + .../item-lifecycle/journal-subagent-item.ts | 25 ++- .../structured-agent-session-subagent-rows.ts | 20 ++- .../NativeChatSubagentRun.fixture.tsx | 68 ++++++++ .../subagents/NativeChatSubagentRun.tsx | 147 +++++++++++++----- .../journal/agent-session-journal-schemas.ts | 11 +- .../agent-session-journal-subagent-schema.ts | 16 ++ .../agent-status-child-work-admission-core.ts | 2 + .../agent-status-child-work-admission.ts | 1 + .../agent-status-child-work-codec.ts | 5 +- ...nt-status-child-work-evidence-admission.ts | 2 + .../agent-status-child-work-evidence.ts | 4 + .../agent-status-child-work-reconciliation.ts | 5 +- .../agent-status-child-work-view-wire.ts | 3 + .../agent-status-child-work-view.ts | 2 + .../child-work/agent-status-child-work.ts | 2 + src/shared/native-chat/native-chat-types.ts | 11 ++ .../native-chat-subagent-entry-detail.ts | 66 ++++++++ .../transcript/native-chat-subagent-items.ts | 7 +- 27 files changed, 482 insertions(+), 77 deletions(-) create mode 100644 src/main/claude/child-work/claude-child-work-evidence.test.ts create mode 100644 src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx create mode 100644 src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts create mode 100644 src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx index 7566b142af4..114baea0532 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx @@ -6,7 +6,12 @@ import { subagentStateLabel } from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-group-header' import { sayMobileNativeChatSubagentGroup as say } from './native-chat-subagent-group-copy' +import { sayMobileNativeChatSessionOption as sayOption } from '../input/native-chat-session-option-copy' import { normalizeSubagentState } from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-summary' +import { + subagentEntryDetail, + subagentEntryRunsOn +} from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-entry-detail' import { formatNativeChatDuration } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-status' import type { NativeChatSubagentGroupBlock, @@ -113,21 +118,31 @@ export function MobileNativeChatSubagentGroup({ {open ? block.agents.map((agent) => { const state = normalizeSubagentState(agent.state) + const detail = [subagentEntryRunsOn(agent, sayOption), subagentEntryDetail(agent)] + .filter((part): part is string => part !== null) + .join(' · ') return ( - - - - {agent.label} - - - {subagentStateLabel(state, 1, 1, say)} - {typeof agent.tokens === 'number' - ? ` · ${formatSubagentTokens(agent.tokens)}` - : null} - + + + + + {agent.label} + + + {subagentStateLabel(state, 1, 1, say)} + {typeof agent.tokens === 'number' + ? ` · ${formatSubagentTokens(agent.tokens)}` + : null} + + + {detail ? ( + + {detail} + + ) : null} ) }) @@ -179,12 +194,20 @@ const styles = themedStyleSheet(() => ({ caretOpen: { transform: [{ rotate: '90deg' }] }, + entryBlock: { + paddingLeft: spacing.lg + spacing.xs + }, entry: { minHeight: 24, flexDirection: 'row', alignItems: 'center', - gap: spacing.sm, - paddingLeft: spacing.lg + spacing.xs + gap: spacing.sm + }, + // Aligns under the label, past the 6pt dot and its gap. + entryDetail: { + paddingLeft: 6 + spacing.sm, + color: colors.textMuted, + fontSize: typography.metaSize }, entryLabel: { flexShrink: 1, diff --git a/src/main/claude/child-work/claude-child-work-evidence.test.ts b/src/main/claude/child-work/claude-child-work-evidence.test.ts new file mode 100644 index 00000000000..d54119516ce --- /dev/null +++ b/src/main/claude/child-work/claude-child-work-evidence.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { claudeChildOperation } from './claude-child-work-evidence' + +describe('Claude child operation evidence', () => { + it("carries the model the subagent's own message ran on", () => { + const frame = { + type: 'assistant', + session_id: 'provider-1', + uuid: 'uuid-1', + parent_tool_use_id: 'toolu_spawn', + message: { + role: 'assistant', + model: 'claude-sonnet-5-5', + content: [{ type: 'tool_use', id: 'toolu_read', name: 'Read', input: {} }] + } + } + const evidence = claudeChildOperation( + frame, + () => ({ agentId: 'agent-1', openTool: null }), + 100 + ) + expect(evidence).toEqual([ + expect.objectContaining({ type: 'operation', childId: 'agent-1', model: 'claude-sonnet-5-5' }) + ]) + }) +}) diff --git a/src/main/claude/child-work/claude-child-work-evidence.ts b/src/main/claude/child-work/claude-child-work-evidence.ts index 6cea1cb0f1f..25d20f281e2 100644 --- a/src/main/claude/child-work/claude-child-work-evidence.ts +++ b/src/main/claude/child-work/claude-child-work-evidence.ts @@ -152,6 +152,7 @@ export function claudeChildOperation( } const { agentId, openTool } = activityOf(parentRef) const input = openTool ? deriveToolInputPreview(openTool.name, openTool.input) : undefined + const model = claudeChildMessageModel(message) return [ { type: 'operation', @@ -159,11 +160,18 @@ export function claudeChildOperation( childId: agentId, operation: openTool ? { toolName: openTool.name, ...(input ? { input } : {}), basis: 'open', observedAt } - : null + : null, + ...(model ? { model } : {}) } ] } +/** Claude names the model on each assistant message; `` marks Claude Code's own repair text. */ +function claudeChildMessageModel(frame: Record): string | undefined { + const model = frame.type === 'assistant' ? claudeRecord(frame.message)?.model : undefined + return typeof model === 'string' && model.trim() && model !== '' ? model : undefined +} + /** Waiting requires both a written journal card and a registry request still awaiting an answer. */ function claudeWaitingChildIds( session: Pick diff --git a/src/main/codex/structured/background-work/codex-background-task-frames.ts b/src/main/codex/structured/background-work/codex-background-task-frames.ts index 8615697d5a3..da52972ec97 100644 --- a/src/main/codex/structured/background-work/codex-background-task-frames.ts +++ b/src/main/codex/structured/background-work/codex-background-task-frames.ts @@ -1,5 +1,5 @@ import type { NativeChatSubagentState } from '../../../../shared/native-chat/native-chat-types' -import { readCodexSubagentAnnouncements } from './codex-subagent-activity' +import { readCodexSubagentAnnouncements, type CodexChildLaunch } from './codex-subagent-activity' import { codexChildTurnState, type CodexSubagentExecutions } from './codex-subagent-executions' import { readRecord } from '../../app-server/codex-item-field-readers' import '../items/codex-structured-item-translation' @@ -11,6 +11,7 @@ export type CodexAnnouncedChild = { label: string | null /** Set only by spawn announcements, whose reporting thread is the spawner. */ spawnerThreadId: string | undefined + launch?: CodexChildLaunch } export type CodexBackgroundTaskFrame = @@ -78,7 +79,8 @@ export function readCodexBackgroundTaskFrame( agentThreadId: announcement.agentThreadId, label: announcement.label, // Only a spawn names the spawner: other announcements ride whichever agent acted. - spawnerThreadId: announcement.spawned ? event.threadId : undefined + spawnerThreadId: announcement.spawned ? event.threadId : undefined, + launch: announcement.launch }) ) return children.length > 0 ? { kind: 'subagents', children } : null @@ -92,7 +94,7 @@ export function observeCodexExecutionFrame( ): void { if (frame?.kind === 'subagents') { for (const child of frame.children) { - executions.register(child.agentThreadId, child.label, child.spawnerThreadId) + executions.register(child.agentThreadId, child.label, child.spawnerThreadId, child.launch) } } else if (frame?.kind === 'thread-closed') { executions.closeThread(frame.threadId) diff --git a/src/main/codex/structured/background-work/codex-child-work-evidence.ts b/src/main/codex/structured/background-work/codex-child-work-evidence.ts index 4c7bba71fab..91c422715da 100644 --- a/src/main/codex/structured/background-work/codex-child-work-evidence.ts +++ b/src/main/codex/structured/background-work/codex-child-work-evidence.ts @@ -266,6 +266,8 @@ export class CodexChildWorkEvidence { residency: 'background', state: facts.waiting ? 'waiting' : 'working', ...(child.label ? { description: child.label } : {}), + ...(child.model ? { model: child.model } : {}), + ...(child.effort ? { effort: child.effort } : {}), ...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}), ...(lastMessage ? { lastMessage } : {}), ...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}), diff --git a/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts b/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts index 24cefaa1870..d54a61cea0d 100644 --- a/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts @@ -198,6 +198,24 @@ describe('Codex structured child-work producer', () => { ]) }) + it('records the model and reasoning effort Codex resolved for the child', async () => { + const { send, records } = await producer() + send(turn('turn/started', THREAD_ID, 'p1')) + send( + item('item/completed', THREAD_ID, 'p1', { + type: 'subAgentActivity', + id: 'spawn-reviewer', + kind: 'started', + agentThreadId: REVIEWER, + agentPath: '/root/inspect_folder', + model: 'gpt-6.1-sol', + reasoning_effort: 'xhigh' + }) + ) + send(turn('turn/started', REVIEWER, 'r1')) + expect(records()).toEqual([expect.objectContaining({ model: 'gpt-6.1-sol', effort: 'xhigh' })]) + }) + it('ends an approval left unanswered when its turn ends: Codex never ran the command', async () => { const { adapter, codex, send, records, recordedLiveness, display, byDescription } = await producer() diff --git a/src/main/codex/structured/background-work/codex-subagent-activity.ts b/src/main/codex/structured/background-work/codex-subagent-activity.ts index 1098dd83f6a..4c8d3ba6d45 100644 --- a/src/main/codex/structured/background-work/codex-subagent-activity.ts +++ b/src/main/codex/structured/background-work/codex-subagent-activity.ts @@ -17,6 +17,8 @@ export type CodexSubagentActivity = { kind: string agentThreadId: string agentPath: string | null + /** What the child resolved to at creation, reported on `started`. */ + launch: CodexChildLaunch } function nonEmptyString(value: unknown): string | null { @@ -40,10 +42,18 @@ export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentA return { kind: nonEmptyString(item.kind) ?? '', agentThreadId, - agentPath: nonEmptyString(item.agentPath) + agentPath: nonEmptyString(item.agentPath), + launch: codexChildLaunch( + nonEmptyString(item.model), + nonEmptyString(item.reasoningEffort) ?? nonEmptyString(item.reasoning_effort) + ) } } +function codexChildLaunch(model: string | null, effort: string | null): CodexChildLaunch { + return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) } +} + const CODEX_ROOT_AGENT_SEGMENT = 'root' /** Normalize root paths like labels; missing paths remain children to avoid losing a real spawn. */ @@ -61,8 +71,12 @@ export type CodexSubagentAnnouncement = { label: string | null /** Only spawn items establish the carrying thread as the parent. */ spawned: boolean + launch?: CodexChildLaunch } +/** What a spawn asked the child to run on. */ +export type CodexChildLaunch = { model?: string; effort?: string } + /** Collab references recover compacted-out spawns; even failed spawns can name a runnable child. */ export function readCodexSubagentAnnouncements( item: CodexThreadItem, @@ -80,7 +94,8 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { { agentThreadId: activity.agentThreadId, label: codexSubagentLabel(activity), - spawned: activity.kind === 'started' + spawned: activity.kind === 'started', + launch: activity.launch } ] } @@ -91,7 +106,8 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { const spawned = call.tool === 'spawnAgent' // Follow-up prompts are messages, not the child's task label. const label = spawned ? codexCollabHelperLabel(call.prompt) : null - return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned })) + const launch = spawned ? codexChildLaunch(call.model, call.reasoningEffort) : undefined + return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned, launch })) } export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } diff --git a/src/main/codex/structured/background-work/codex-subagent-executions.ts b/src/main/codex/structured/background-work/codex-subagent-executions.ts index ceb01772b8c..1a8983e112c 100644 --- a/src/main/codex/structured/background-work/codex-subagent-executions.ts +++ b/src/main/codex/structured/background-work/codex-subagent-executions.ts @@ -1,4 +1,5 @@ import type { NativeChatSubagentState } from '../../../../shared/native-chat/native-chat-types' +import type { CodexChildLaunch } from './codex-subagent-activity' import { MAX_SUBAGENT_FIELD_CHARS, canReplaceSubagentState @@ -20,6 +21,8 @@ export type CodexExecutionChild = { label: string | null /** Spawn announcements arrive on the parent's thread. */ spawnerThreadId: string | null + model: string | null + effort: string | null execution: CodexChildExecution | null turnOrdinals: Map turnCount: number @@ -37,12 +40,15 @@ export class CodexSubagentExecutions { register( agentThreadId: string, label: string | null, - spawnerThreadId?: string + spawnerThreadId?: string, + launch?: CodexChildLaunch ): CodexExecutionChild { const child = this.child(agentThreadId) - const previousLabel = child.label + const previous = [child.label, child.model, child.effort] // Keep the original spawner across duplicate announcement frames. child.spawnerThreadId ??= spawnerThreadId ?? null + child.model ??= launch?.model ?? null + child.effort ??= launch?.effort ?? null child.registered = true // Retain one overflow unit so the journal can append its per-row truncation marker. child.label ??= @@ -51,7 +57,9 @@ export class CodexSubagentExecutions { .replace(/\s+/g, ' ') .slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null this.updateWorking(child) - if (previousLabel !== child.label) { + if ( + [child.label, child.model, child.effort].some((value, index) => value !== previous[index]) + ) { this.changed.add(agentThreadId) } return child @@ -210,6 +218,8 @@ export class CodexSubagentExecutions { observationOrder: this.children.size, registered: false, label: null, + model: null, + effort: null, spawnerThreadId: null, execution: null, turnOrdinals: new Map(), diff --git a/src/main/codex/structured/items/codex-collab-agent-tool-call.ts b/src/main/codex/structured/items/codex-collab-agent-tool-call.ts index cf20a540329..77606f2ddd9 100644 --- a/src/main/codex/structured/items/codex-collab-agent-tool-call.ts +++ b/src/main/codex/structured/items/codex-collab-agent-tool-call.ts @@ -20,6 +20,9 @@ export type CodexCollabAgentToolCall = { /** The receivers that are helpers: every one but those the call reports `notFound`. */ helperThreadIds: string[] prompt: string | null + /** What a spawn asked the child to run on. */ + model: string | null + reasoningEffort: string | null /** Caller snapshots in receiverThreadIds order, excluding receivers with no snapshot. */ states: { threadId: string; status: string | null; message: string | null }[] } @@ -53,6 +56,8 @@ export function readCodexCollabAgentToolCall( (threadId) => readString(readRecord(snapshots[threadId]), 'status') !== 'notFound' ), prompt: readString(item, 'prompt'), + model: readString(item, 'model'), + reasoningEffort: readString(item, 'reasoningEffort'), states } } diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts index 11e65de4d02..aee9589d4b1 100644 --- a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts @@ -26,7 +26,18 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna label: boundSubagentLabel(block.label), groupId: boundSubagentEntryId(block.groupId), ...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}), - ...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}) + ...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}), + ...boundOptionalLabels(block), + ...(block.operation !== undefined + ? { + operation: { + toolName: boundSubagentLabel(block.operation.toolName), + ...(block.operation.input !== undefined + ? { input: boundSubagentLabel(block.operation.input) } + : {}) + } + } + : {}) } return { kind: 'message', @@ -35,6 +46,18 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna } } +const OPTIONAL_LABELS = ['agentType', 'model', 'effort', 'lastMessage'] as const + +function boundOptionalLabels( + block: NativeChatSubagentBlock +): Pick { + return Object.fromEntries( + OPTIONAL_LABELS.flatMap((key) => + block[key] === undefined ? [] : [[key, boundSubagentLabel(block[key])]] + ) + ) +} + export function journaledSubagent( journal: { itemBody: (itemId: string) => AgentJournalItemBody | null }, id: string diff --git a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts index 387085a598b..f7b84b1cfcb 100644 --- a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts +++ b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts @@ -181,7 +181,7 @@ export class StructuredAgentSessionSubagentRows { } } -/** Retain saved name, tokens and parent when a later run lacks them. */ +/** Retain saved name, tokens, parent, role and model when a later run lacks them. */ function subagentRowBlock( child: AgentChildWorkRowView, id: string, @@ -201,6 +201,12 @@ function subagentRowBlock( : saved.run === run ? (saved.startedAt ?? child.firstObservedAt) : child.observedAt + const agentType = child.agentType ?? saved?.agentType + const model = child.model ?? saved?.model + const effort = child.effort ?? saved?.effort + const operation = state === 'working' ? child.operation : undefined + // A new run's silence must not repeat the previous run's result. + const lastMessage = child.lastMessage ?? (saved?.run === run ? saved.lastMessage : undefined) return { type: 'subagent', id, @@ -209,6 +215,18 @@ function subagentRowBlock( ...(tokens !== undefined ? { tokens } : {}), startedAt, ...(state !== 'working' && child.settledAt !== undefined ? { settledAt: child.settledAt } : {}), + ...(agentType !== undefined ? { agentType } : {}), + ...(model !== undefined ? { model } : {}), + ...(effort !== undefined ? { effort } : {}), + ...(operation !== undefined + ? { + operation: { + toolName: operation.toolName, + ...(operation.input !== undefined ? { input: operation.input } : {}) + } + } + : {}), + ...(lastMessage !== undefined ? { lastMessage } : {}), groupId: saved?.groupId ?? (scope.kind === 'turn' ? scope.turnItemId : THREAD_GROUP), ...(parent !== undefined ? { parentId: parent } : {}), run, diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx new file mode 100644 index 00000000000..fd2881cdd75 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx @@ -0,0 +1,68 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import type { NativeChatSubagentGroupBlock } from '../../../../../shared/native-chat/native-chat-types' +import { NativeChatSubagentRun } from './NativeChatSubagentRun' + +const now = Date.now() + +const block: NativeChatSubagentGroupBlock = { + type: 'subagent-group', + groupId: 'turn-1', + agents: [ + { + id: 'a1', + label: 'Audit auth flow', + state: 'working', + tokens: 31_200, + startedAt: now - 42_000, + agentType: 'general-purpose', + model: 'claude-opus-5-5', + effort: 'high', + operation: { toolName: 'Read', input: 'src/auth/session.ts' } + }, + { + id: 'a2', + label: 'Fix flaky login test', + state: 'working', + tokens: 48_900, + startedAt: now - 63_000, + model: 'gpt-5.5', + effort: 'xhigh', + operation: { toolName: 'Bash', input: 'pnpm test login.spec.ts' } + }, + { + id: 'a3', + label: 'Map token refresh', + state: 'completed', + tokens: 8_100, + startedAt: now - 80_000, + settledAt: now - 62_000, + model: 'claude-haiku-4-5-20251001', + lastMessage: + 'Refresh happens in **two** places:\n- `middleware.ts` retries once on 401\n- the client refreshes on a timer 60 s before expiry' + } + ] +} + +function Roster() { + return ( +
+ {}} /> +
+ ) +} + +export const enriched = defineFixture({ + width: 560, + render: () => +}) + +export const hovered = defineFixture({ + width: 560, + render: () => , + interact: async (frame) => { + frame + .querySelector('[data-native-chat-subagent-entry="a1"] > div') + ?.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' })) + await new Promise((resolve) => setTimeout(resolve, 500)) + } +}) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx index c334523dea5..aba13b19bc6 100644 --- a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Bot, ChevronRight } from 'lucide-react' import { cn } from '@/lib/class-names' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { useNow } from '@/hooks/use-now' import { formatSubagentTokens, @@ -8,6 +9,12 @@ import { subagentStateLabel } from '../../../../../shared/native-chat/transcript/native-chat-subagent-group-header' import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary' +import { + subagentEntryDetail, + subagentEntryRunsOn, + subagentMessageText, + subagentRoleLabel +} from '../../../../../shared/native-chat/transcript/native-chat-subagent-entry-detail' import type { NativeChatSubagentEntry, NativeChatSubagentGroupBlock, @@ -16,6 +23,7 @@ import type { import { formatNativeChatDuration } from '../../../../../shared/native-chat/transcript/native-chat-turn-status' import { nativeChatSubagentEntryRuns } from './native-chat-subagent-sections' import { sayNativeChatSubagentGroupTranslated as say } from './native-chat-subagent-group-words-text' +import { sayNativeChatSessionOptionTranslated as sayOption } from '../session/options/controls/native-chat-session-option-labels-text' const STATE_DOT_CLASS: Record = { working: 'bg-foreground/70', @@ -87,47 +95,65 @@ export function NativeChatSubagentEntries({ {agents.map((agent) => { const state = normalizeSubagentState(agent.state) const sectionOpen = sections?.get(agent.id) + const detail = subagentEntryDetail(agent) + const runsOn = subagentEntryRunsOn(agent, sayOption) const entry = ( - <> - - - {agent.label} + + + + + {agent.label} + + {runsOn ? ( + + {runsOn} + + ) : null} + + {subagentStateLabel(state, 1, 1, say)} + {typeof agent.tokens === 'number' + ? ` · ${formatSubagentTokens(agent.tokens)}` + : null} + - - {subagentStateLabel(state, 1, 1, say)} - {typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null} - - + {detail ? ( + + {detail} + + ) : null} + ) return ( // Named so a reveal from the agents strip can bring this entry into view.
  • - {sectionOpen === undefined ? ( -
    {entry}
    - ) : ( - - )} + + {sectionOpen === undefined ? ( +
    {entry}
    + ) : ( + + )} +
  • ) })} @@ -135,6 +161,57 @@ export function NativeChatSubagentEntries({ ) } +/** Hover preview: role, model, timing and the longer text the row truncates. */ +function SubagentEntryPreview({ + agent, + state, + detail, + runsOn, + children +}: { + agent: NativeChatSubagentEntry + state: NativeChatSubagentState + detail: string | null + runsOn: string | null + children: React.ReactElement +}): React.JSX.Element { + const role = [subagentRoleLabel(agent.agentType), runsOn].filter(Boolean).join(' · ') + const text = + state === 'working' && agent.operation + ? detail + : subagentMessageText(agent.lastMessage ?? '') || null + return ( + + {children} + +
    +
    {agent.label}
    + {role ?
    {role}
    : null} +
    + {subagentStateLabel(state, 1, 1, say)} + {agent.startedAt !== undefined ? ( + <> + {' · '} + + + ) : null} + {typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null} +
    + {text ? ( +

    + {text} +

    + ) : null} +
    +
    +
    + ) +} + /** Trust journal child states: children outlive their spawning turn, and only host/provider settlement proves lost contact. */ export function NativeChatSubagentRun({ block, diff --git a/src/shared/agent-session/journal/agent-session-journal-schemas.ts b/src/shared/agent-session/journal/agent-session-journal-schemas.ts index 4bb154daff9..6aba91ad651 100644 --- a/src/shared/agent-session/journal/agent-session-journal-schemas.ts +++ b/src/shared/agent-session/journal/agent-session-journal-schemas.ts @@ -10,6 +10,7 @@ import { AgentSessionContextUsageSchema } from '../agent-session-context-usage-s import { AgentJournalThreadGoalStateSchema } from './agent-session-journal-thread-goal-schema' import { AgentSessionFailureFactSchema } from '../failures/agent-session-failure-fact-schema' import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' +import { SubagentEntry } from './agent-session-journal-subagent-schema' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -43,16 +44,6 @@ const ProviderCallId = z .string() .refine((value) => value.trim().length > 0, 'callId must contain a non-whitespace character') -/** Keep lifecycle strings open so newer states do not make persisted rows malformed. */ -const SubagentEntry = z.object({ - id: z.string(), - label: z.string(), - state: z.string().min(1), - tokens: z.number().optional(), - startedAt: z.number().optional(), - settledAt: z.number().optional() -}) - /** Skip unknown block types, but reject malformed payloads of known types. */ const Block = openDiscriminatedUnion( z.discriminatedUnion('type', [ diff --git a/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts b/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts new file mode 100644 index 00000000000..06f37eb251a --- /dev/null +++ b/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts @@ -0,0 +1,16 @@ +import * as z from 'zod' + +/** Keep lifecycle strings open so newer states do not make persisted rows malformed. */ +export const SubagentEntry = z.object({ + id: z.string(), + label: z.string(), + state: z.string().min(1), + tokens: z.number().optional(), + startedAt: z.number().optional(), + settledAt: z.number().optional(), + agentType: z.string().optional(), + model: z.string().optional(), + effort: z.string().optional(), + operation: z.object({ toolName: z.string(), input: z.string().optional() }).optional(), + lastMessage: z.string().optional() +}) diff --git a/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts b/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts index 1f97c495586..59aa5b739aa 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts @@ -180,6 +180,7 @@ function parseObservationFacts( ), agentType: normalizeChildWorkText(request.agentType, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), model: normalizeChildWorkText(request.model, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), + effort: normalizeChildWorkText(request.effort, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), totalTokens: isChildWorkTokenCount(request.totalTokens) ? request.totalTokens : undefined, providerTiming: timing ?? undefined, parentChildWorkId: isAgentChildWorkOwner(request.parentChildWorkId, host.childWorkId) @@ -206,6 +207,7 @@ function mergeObservationFacts( description: said.description ?? prior?.description, agentType: said.agentType ?? prior?.agentType, model: said.model ?? prior?.model, + effort: said.effort ?? prior?.effort, residency: said.residency ?? prior?.residency, // Cumulative, so a late or duplicate frame never shrinks it. totalTokens: diff --git a/src/shared/agent-status/child-work/agent-status-child-work-admission.ts b/src/shared/agent-status/child-work/agent-status-child-work-admission.ts index 5a3afc5a912..f835b2d8d8a 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-admission.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-admission.ts @@ -41,6 +41,7 @@ export type AgentChildWorkObservationFields = { description?: string agentType?: string model?: string + effort?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming parentChildWorkId?: AgentChildWorkId diff --git a/src/shared/agent-status/child-work/agent-status-child-work-codec.ts b/src/shared/agent-status/child-work/agent-status-child-work-codec.ts index a8a6bc071aa..d0f47dc7b5f 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-codec.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-codec.ts @@ -162,6 +162,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | 'description', 'agentType', 'model', + 'effort', 'totalTokens', 'providerTiming', 'parentChildWorkId', @@ -204,7 +205,8 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | name: parseOptionalLabel(value.name), description: parseOptionalLabel(value.description, AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH), agentType: parseOptionalLabel(value.agentType), - model: parseOptionalLabel(value.model) + model: parseOptionalLabel(value.model), + effort: parseOptionalLabel(value.effort) } const activity = parseAgentChildWorkActivityFields(value, { childWorkId: value.childWorkId, @@ -268,6 +270,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | ...(labels.description ? { description: labels.description } : {}), ...(labels.agentType ? { agentType: labels.agentType } : {}), ...(labels.model ? { model: labels.model } : {}), + ...(labels.effort ? { effort: labels.effort } : {}), ...(typeof value.totalTokens === 'number' ? { totalTokens: value.totalTokens } : {}), ...(timing ? { providerTiming: timing } : {}), ...activity, diff --git a/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts index e20a90c2166..2fdcf069d77 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts @@ -101,6 +101,8 @@ function liveFields( ...(child.name !== undefined ? { name: child.name } : {}), ...(child.description !== undefined ? { description: child.description } : {}), ...(child.agentType !== undefined ? { agentType: child.agentType } : {}), + ...(child.model !== undefined ? { model: child.model } : {}), + ...(child.effort !== undefined ? { effort: child.effort } : {}), ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), ...(owner !== undefined ? { parentChildWorkId: owner } : {}), residency: child.residency, diff --git a/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts b/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts index 7abe7298c00..ad31d483a27 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts @@ -27,6 +27,8 @@ export type AgentChildWorkLiveObservation = { name?: string description?: string agentType?: string + model?: string + effort?: string totalTokens?: number /** `null`: the operation that was open has ended. Absent: this edge says nothing about it. */ operation?: AgentChildWorkOperation | null @@ -52,6 +54,8 @@ export type AgentChildWorkOperationEvidence = { childId: string /** `null`: the call it had open has ended. */ operation: AgentChildWorkOperation | null + /** The model the child's own message ran on. */ + model?: string } /** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. */ diff --git a/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts b/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts index 58930bd110b..111e374a481 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts @@ -70,7 +70,7 @@ function applyStopAcknowledged( } } -/** A live child's current operation, and nothing else about it. */ +/** A live child's current operation and the model it runs on, and nothing else about it. */ function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvidence): void { const resolution = resolveAgentChildWorkHandle( ctx, @@ -93,7 +93,8 @@ function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvid residency: record.residency, state: record.state, stoppable: record.stoppable, - operation: edge.operation + operation: edge.operation, + ...(edge.model !== undefined ? { model: edge.model } : {}) }, edge.observedAt, false diff --git a/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts b/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts index 0fb7ad8557c..c0bbeceda8e 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts @@ -87,6 +87,7 @@ function decodeView(value: unknown): AgentChildWorkView | null { const description = text(view.description) const agentType = text(view.agentType) const model = text(view.model) + const effort = text(view.effort) const lastMessage = text(view.lastMessage) const parentChildWorkId = text(view.parentChildWorkId) const totalTokens = clock(view.totalTokens) @@ -97,6 +98,7 @@ function decodeView(value: unknown): AgentChildWorkView | null { ...(description ? { description } : {}), ...(agentType ? { agentType } : {}), ...(model ? { model } : {}), + ...(effort ? { effort } : {}), ...(lastMessage ? { lastMessage } : {}), ...(parentChildWorkId ? { parentChildWorkId } : {}), kind: arm(KINDS, view.kind) ?? 'unknown', @@ -150,6 +152,7 @@ function viewsEqual(a: AgentChildWorkView, b: AgentChildWorkView, clockTolerance a.description === b.description && a.agentType === b.agentType && a.model === b.model && + a.effort === b.effort && a.state === b.state && a.membership === b.membership && a.outcome === b.outcome && diff --git a/src/shared/agent-status/child-work/agent-status-child-work-view.ts b/src/shared/agent-status/child-work/agent-status-child-work-view.ts index 48b027fcc36..6e9199d90dd 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-view.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-view.ts @@ -27,6 +27,7 @@ export type AgentChildWorkView = { description?: string agentType?: string model?: string + effort?: string state: AgentChildWorkState membership: AgentChildWorkMembership outcome?: AgentChildWorkOutcome @@ -126,6 +127,7 @@ export function projectAgentChildWorkViews( ...(record.description !== undefined ? { description: record.description } : {}), ...(record.agentType !== undefined ? { agentType: record.agentType } : {}), ...(record.model !== undefined ? { model: record.model } : {}), + ...(record.effort !== undefined ? { effort: record.effort } : {}), state: record.state, membership: record.membership, ...(record.outcome !== undefined ? { outcome: record.outcome } : {}), diff --git a/src/shared/agent-status/child-work/agent-status-child-work.ts b/src/shared/agent-status/child-work/agent-status-child-work.ts index 4199aa6e66d..f86eed8763d 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work.ts @@ -84,6 +84,8 @@ export type AgentChildWorkInput = { description?: string agentType?: string model?: string + /** Reasoning effort, in the provider's own words (`high`, `medium`). */ + effort?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming /** Invocation owner (spawner or launching agent); absence identifies the session lead. */ diff --git a/src/shared/native-chat/native-chat-types.ts b/src/shared/native-chat/native-chat-types.ts index d7590bdc32a..d0f6a63962d 100644 --- a/src/shared/native-chat/native-chat-types.ts +++ b/src/shared/native-chat/native-chat-types.ts @@ -137,8 +137,19 @@ export type NativeChatSubagentEntry = { startedAt?: number /** Epoch ms the entry latched terminal. */ settledAt?: number + /** The child's role (Claude's `subagent_type`), when it has one. */ + agentType?: string + model?: string + /** Reasoning effort, in the provider's own words. */ + effort?: string + /** The tool it is running now; only while it works. */ + operation?: NativeChatSubagentOperation + /** The newest thing it said; once settled, its result. */ + lastMessage?: string } +export type NativeChatSubagentOperation = { toolName: string; input?: string } + /** Provider-neutral roster revised in place by Claude/Codex activity. */ export type NativeChatSubagentGroupBlock = { type: 'subagent-group' diff --git a/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts b/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts new file mode 100644 index 00000000000..0292be4605c --- /dev/null +++ b/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts @@ -0,0 +1,66 @@ +// What a subagent roster entry says under its name, shared by desktop and mobile. + +import { formatAgentToolPreviewLine } from '../../agent-status/presentation/agent-tool-preview' +import { claudeListedModelLabel } from '../../claude-model-list-probe' +import { + nativeChatSessionChoiceShortLabel, + type NativeChatSessionOptionSay +} from '../input/native-chat-session-option-labels' +import type { NativeChatSubagentEntry } from '../native-chat-types' +import { normalizeSubagentState } from './native-chat-subagent-summary' + +/** While it works: the tool it runs, else what it last said. Once settled: what it said last, its result. */ +export function subagentEntryDetail(entry: NativeChatSubagentEntry): string | null { + const working = normalizeSubagentState(entry.state) === 'working' + const operation = + working && entry.operation + ? formatAgentToolPreviewLine( + { toolName: entry.operation.toolName, toolInput: entry.operation.input }, + false + ) + : '' + // Lines become separate clauses, so list items don't run together on one row. + const text = + operation || + subagentMessageText(entry.lastMessage ?? '') + .replace(/:\s*\n\s*/g, ': ') + .replace(/\s*\n\s*/g, ' · ') + .replace(/\s+/g, ' ') + return text || null +} + +/** What the subagent said, without the markdown marks a plain row or preview can't render. */ +export function subagentMessageText(markdown: string): string { + return markdown + .replace(/^\s*```.*$/gm, '') + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+)/gm, '') + .replace(/[`*~]|__/g, '') + .trim() +} + +/** `claude-opus-5-5` reads as `Opus 5.5`, `gpt-5.5` as `GPT-5.5`: the chat already names the provider. */ +export function subagentModelLabel(model: string): string { + return claudeListedModelLabel(model.trim()) + .replace(/^Claude /, '') + .replace(/^GPT (\d)/, 'GPT-$1') +} + +/** `general-purpose` reads as `General purpose`; Codex's bare `default` role says nothing. */ +export function subagentRoleLabel(agentType: string | undefined): string | null { + const words = agentType?.replace(/[-_]+/g, ' ').trim() + return words && words !== 'default' ? words.charAt(0).toUpperCase() + words.slice(1) : null +} + +/** `Opus 5.5 · High`, from whichever the provider reported. */ +export function subagentEntryRunsOn( + entry: NativeChatSubagentEntry, + say: NativeChatSessionOptionSay +): string | null { + const model = entry.model?.trim() ? subagentModelLabel(entry.model) : null + const effort = entry.effort?.trim() + ? nativeChatSessionChoiceShortLabel({ value: entry.effort, label: entry.effort }, say, 'effort') + : null + const parts = [model, effort].filter((part): part is string => part !== null) + return parts.length > 0 ? parts.join(' · ') : null +} diff --git a/src/shared/native-chat/transcript/native-chat-subagent-items.ts b/src/shared/native-chat/transcript/native-chat-subagent-items.ts index dd5f357fb2b..eecea9e1771 100644 --- a/src/shared/native-chat/transcript/native-chat-subagent-items.ts +++ b/src/shared/native-chat/transcript/native-chat-subagent-items.ts @@ -32,7 +32,12 @@ export function subagentEntryOf( state: block.state, ...(block.tokens !== undefined ? { tokens: block.tokens } : {}), ...(block.startedAt !== undefined ? { startedAt: block.startedAt } : {}), - ...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {}) + ...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {}), + ...(block.agentType !== undefined ? { agentType: block.agentType } : {}), + ...(block.model !== undefined ? { model: block.model } : {}), + ...(block.effort !== undefined ? { effort: block.effort } : {}), + ...(block.operation !== undefined ? { operation: block.operation } : {}), + ...(block.lastMessage !== undefined ? { lastMessage: block.lastMessage } : {}) } }