Files
orca/src/shared/native-chat-subagent-group-header.ts
T
Brennan Benson 2460068883 feat(mobile): show a native chat's subagents like desktop (#26125)
* feat(mobile): show a native chat's subagents like desktop

Move the background-tasks roster, header, view and the transcript roster
header into src/shared, with the words behind one English table that desktop
translates under its existing keys, so desktop output is unchanged.

The phone now draws the running child work strip above the composer (with
Stop, under desktop's conditions) and the transcript's subagent-group row,
from the host data it already receives. Child rows read unverifiable once the
session stream is lost.

* fix(mobile): review round 1 for the phone's subagent strip and roster row

- Roster row: spoken through one label that names its clock only once the group
  settles; the verdict wraps instead of pushing the headline or chevron out.
- One twin rule: derive() and the phone both call isReplacedSubagentGroupTwin.
- Desktop strip says Stop / Stop all / details-unavailable through the shared table.
- One memoized composer tray holds the strip and the queued cards; the strip
  carries its own conversation key.
- The strip latches the host clock offset once per conversation, so frames that
  change nothing keep its rows memoized, and reads elapsed on the host's clock.
- Initial narrow guess includes the strip's margins; group labels uppercase by
  style; the header ellipsizes at large text; row state words reuse mobile's.

* fix(mobile): give the child-work strip its own key beside the queued cards

Both siblings in the composer tray were keyed by the conversation's sessionKey, so
React logged a duplicate-key error whenever a structured chat with running
subagents opened.

* fix(mobile): re-derive the strip's host clock offset from every frame

The offset was latched per conversation, so a host restart or clock correction
while the chat stayed open left every elapsed time off until the chat was left.
It is now derived from each frame's host sample, rounded to a second so jitter
alone keeps the rows memoized.

* feat(native-chat): keep one kind's state forms on a narrow strip

The narrow total ("N background tasks") now replaces only a breakdown across
several kinds. A single kind keeps its state forms at every width, so a phone and
a narrow desktop pane still read "2 agents waiting — needs approval".

* test(native-chat): check catalog objects before reading keys
2026-10-07 15:50:15 -07:00

161 lines
5.7 KiB
TypeScript

// One spawn group's header line — headline, verdict, alert, clock and tokens — decided once for
// every transcript that draws a roster row (desktop and the phone). Each passes its own `say`.
import { normalizeSubagentState, summarizeSubagentGroup } from './native-chat-subagent-summary'
import type { NativeChatSubagentEntry, NativeChatSubagentState } from './native-chat-types'
export const NATIVE_CHAT_SUBAGENT_GROUP_COPY = {
stateCompleted: 'completed',
stateWorking: 'working',
stateIdle: 'idle',
stateFailed: 'failed',
stateStopped: 'stopped',
stateUnverifiable: 'status unavailable',
stateWorkingCount: '{{value0}} working',
stateIdleCount: '{{value0}} idle',
stateFailedCount: '{{value0}} failed',
stateStoppedCount: '{{value0}} stopped',
stateUnverifiableCount: '{{value0}} with status unavailable',
startedOne: 'Kicked off 1 subagent',
startedN: 'Kicked off {{value0}} subagents',
ranOne: 'Ran 1 subagent',
ranN: 'Ran {{value0}} subagents',
tokens: '{{value0}} tokens'
} as const
export type NativeChatSubagentGroupCopyId = keyof typeof NATIVE_CHAT_SUBAGENT_GROUP_COPY
export type NativeChatSubagentGroupSay = (
id: NativeChatSubagentGroupCopyId,
values?: { value0: string | number }
) => string
/** Any surface without translations. */
export const sayNativeChatSubagentGroupEnglish: NativeChatSubagentGroupSay = (id, values) =>
values === undefined
? NATIVE_CHAT_SUBAGENT_GROUP_COPY[id]
: NATIVE_CHAT_SUBAGENT_GROUP_COPY[id].replaceAll('{{value0}}', () => String(values.value0))
/** Compact token counts: the row shows scale, not an exact ledger. */
export function formatSubagentTokens(tokens: number): string {
if (tokens < 1_000) {
return String(Math.round(tokens))
}
const scaled = tokens < 1_000_000 ? tokens / 1_000 : tokens / 1_000_000
const suffix = tokens < 1_000_000 ? 'k' : 'M'
return `${scaled.toFixed(1).replace(/\.0$/, '')}${suffix}`
}
/** The group's one-line verdict. A single-child group reads as a bare word; any
* larger group always carries the count, because "working" alone would not say
* how many of the children it covers. `completed` never takes one: every child
* finishing is the whole group finishing. */
export function subagentStateLabel(
state: NativeChatSubagentState,
count: number,
groupTotal: number,
say: NativeChatSubagentGroupSay
): string {
if (state === 'completed') {
return say('stateCompleted')
}
if (groupTotal <= 1) {
switch (state) {
case 'working':
return say('stateWorking')
case 'idle':
return say('stateIdle')
case 'failed':
return say('stateFailed')
case 'stopped':
return say('stateStopped')
case 'unverifiable':
return say('stateUnverifiable')
}
}
const value = { value0: count }
switch (state) {
case 'working':
return say('stateWorkingCount', value)
case 'idle':
return say('stateIdleCount', value)
case 'failed':
return say('stateFailedCount', value)
case 'stopped':
return say('stateStoppedCount', value)
case 'unverifiable':
return say('stateUnverifiableCount', value)
}
}
export type NativeChatSubagentGroupHeader = {
total: number
working: boolean
/** "Kicked off 2 subagents" while any child works, else "Ran 2 subagents". */
headline: string
verdictState: NativeChatSubagentState
verdict: string
/** An adverse outcome already recorded while siblings still work; null otherwise. */
alertState: NativeChatSubagentState | null
alert: string | null
/** Where the elapsed clock starts; null when the run length is unknown. */
clockStartedAt: number | null
settledAt: number | null
/** "12k tokens", or null when no child reported any. */
tokens: string | null
}
export function nativeChatSubagentGroupHeader(
agents: readonly NativeChatSubagentEntry[],
say: NativeChatSubagentGroupSay
): NativeChatSubagentGroupHeader {
const summary = summarizeSubagentGroup(agents)
const working = summary.working > 0
const headline = working
? summary.total === 1
? say('startedOne')
: say('startedN', { value0: summary.total })
: summary.total === 1
? say('ranOne')
: say('ranN', { value0: summary.total })
const verdictState: NativeChatSubagentState = working
? 'working'
: (summary.settledState ?? 'idle')
const verdict = working
? subagentStateLabel('working', summary.working, summary.total, say)
: subagentStateLabel(verdictState, summary.settledCount, summary.total, say)
// A child that already failed must not wait for its siblings to be readable.
const alertState = working ? summary.adverseState : null
const alert =
alertState === null
? null
: subagentStateLabel(alertState, summary.adverseCount, summary.total, say)
// A child settled with no terminal stamp — swept by the reopen, or given the
// provider's verdict after that — stopped being observable at an unknown
// moment. Measuring to `now` would report the time since the host died as how
// long the child ran, on a row that is not even counting. A sibling's stamp is
// no better: in a mixed group it would present that sibling's duration as the
// group's while a child's run length is still unknown.
const runLengthUnknown = agents.some(
(agent) =>
normalizeSubagentState(agent.state) !== 'working' && typeof agent.settledAt !== 'number'
)
const clockStartedAt =
!runLengthUnknown && (working || summary.settledAt !== null) ? summary.clockStartedAt : null
return {
total: summary.total,
working,
headline,
verdictState,
verdict,
alertState,
alert,
clockStartedAt,
settledAt: summary.settledAt,
tokens:
summary.tokens !== null
? say('tokens', { value0: formatSubagentTokens(summary.tokens) })
: null
}
}