Files
orca/src/shared/native-chat-turn-status.ts
T
Brennan BensonandMerge Sim fab78c7669 fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977)
* native-chat: render one indicator row for the live desktop turn

The turn-timing row and the spinner+activity line were two rows saying
"Working" at once. A settled turn keeps its own row; the live turn now has
only the spinner row, labelled provider activity -> Thinking -> Working for N
through the shared resolver. Reasoning is the turn's content, so it no longer
becomes the activity label, and "Thinking" now means the turn is reasoning
right now rather than that it has produced no output yet.

* mobile: give the live turn row a spinner and the shared indicator label

Mobile's per-turn row is already the only live indicator on the structured
lane, but it pulsed a bare word and never showed what the provider said it was
doing. It now renders a spinner beside the same resolved label desktop uses,
and reads reasoning from the journal instead of inferring it from missing
output. The bridge lane's four prompt/interrupt write seams move to one module
so the controller stays under its line cap.

* codex: mark streamed reasoning as reasoning too, and pin the provider markers

The settled reasoning item carried the marker but the streaming one did not,
so a live Codex turn - the only time the indicator is on screen - never read
as reasoning. Both paths now stamp it; a plan document keeps its own
presentation and must never read as reasoning.

* fix(native-chat): tighten live turn reasoning state

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 00:19:57 -07:00

260 lines
9.0 KiB
TypeScript

// Turn-status derivation and copy for the native-chat "Thinking / Working for N /
// Worked for N" row, shared by the desktop renderer (as its i18n fallback strings)
// and the mobile app (used directly — mobile ships English only) so the two
// surfaces never drift. Everything here is pure; each platform owns its own clock.
export const NATIVE_CHAT_TURN_STATUS_COPY = {
thinking: 'Thinking',
workingFor: 'Working for {{value0}}',
workedFor: 'Worked for {{value0}}',
toggleDetails: 'Toggle turn details',
responding: 'Agent is responding'
} as const
/** Format turn time without exposing an ever-growing raw seconds count. */
export function formatNativeChatDuration(seconds: number): string {
const totalSeconds = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0
if (totalSeconds < 60) {
return `${totalSeconds}s`
}
const minutes = Math.floor(totalSeconds / 60)
const remainingSeconds = totalSeconds % 60
if (minutes < 60) {
return `${minutes}m ${remainingSeconds}s`
}
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m ${remainingSeconds}s`
}
/** Which of the three copy keys a turn-status row renders, and its duration
* argument. Desktop maps this onto `translate`; mobile formats it directly. */
export function describeNativeChatTurnStatus({
thinking,
workedSeconds,
elapsedSeconds
}: {
thinking: boolean
workedSeconds?: number | null
elapsedSeconds: number
}): { key: 'thinking' | 'workingFor' | 'workedFor'; duration: string | null } {
if (workedSeconds != null) {
return { key: 'workedFor', duration: formatNativeChatDuration(workedSeconds) }
}
if (thinking) {
return { key: 'thinking', duration: null }
}
return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) }
}
/** The two readings that label a live turn's one indicator row, carried together
* so a surface cannot pick up one without the other. */
export type NativeChatLiveTurnIndicator = {
thinking: boolean
activityText: string | null
}
export type NativeChatActiveTurnLabel =
| { source: 'activity'; text: string }
| { source: 'status'; key: 'thinking' | 'workingFor'; duration: string | null }
/** The live turn's single indicator label. Provider activity wins because it is the
* only text that says what the turn is actually doing; reasoning is next; the
* running clock is the floor. Shared so desktop and mobile cannot disagree. */
export function describeNativeChatActiveTurnLabel({
activityText,
thinking,
elapsedSeconds
}: {
activityText?: string | null
thinking: boolean
elapsedSeconds: number
}): NativeChatActiveTurnLabel {
const text = activityText?.trim()
if (text) {
return { source: 'activity', text }
}
return thinking
? { source: 'status', key: 'thinking', duration: null }
: { source: 'status', key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) }
}
/** The live turn's label in English. For platforms without i18n (mobile). */
export function formatNativeChatActiveTurnLabel(input: {
activityText?: string | null
thinking: boolean
elapsedSeconds: number
}): string {
const label = describeNativeChatActiveTurnLabel(input)
if (label.source === 'activity') {
return label.text
}
const copy = NATIVE_CHAT_TURN_STATUS_COPY[label.key]
return label.duration == null ? copy : copy.replaceAll('{{value0}}', label.duration)
}
/** Resolve the turn-status label in English. For platforms without i18n (mobile). */
export function formatNativeChatTurnStatusLabel(input: {
thinking: boolean
workedSeconds?: number | null
elapsedSeconds: number
}): string {
const { key, duration } = describeNativeChatTurnStatus(input)
const copy = NATIVE_CHAT_TURN_STATUS_COPY[key]
return duration == null ? copy : copy.replaceAll('{{value0}}', duration)
}
export type NativeChatTurnTiming = {
startedAt: number
workedSeconds: number | null
}
export type NativeChatTurnStatus = {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
}
export type NativeChatTurnTimingByTurn = Readonly<Record<string, NativeChatTurnTiming>>
/** The turn-timing state machine, lifted out of the React hook so desktop and
* mobile stamp start/stop identically. Returns the same reference when nothing
* changed so callers can bail out of a state update. */
export function reduceNativeChatTurnTiming(
current: NativeChatTurnTimingByTurn,
{
activeTurnKey,
previousActiveTurnKey,
validTurnKeys,
isWorking,
workingStartedAt,
now
}: {
activeTurnKey: string
/** The key this turn had on the previous pass. When it names a turn that has
* since left the transcript, the two are the same turn under two ids — an
* optimistic echo that the transcript replaced — so the clock carries over
* instead of restarting. Omit it to keep the plain restart behavior. */
previousActiveTurnKey?: string
validTurnKeys: ReadonlySet<string>
isWorking: boolean
workingStartedAt?: number | null
now: number
}
): NativeChatTurnTimingByTurn {
// The same turn under two ids: an optimistic echo the transcript has since
// replaced. Re-key its timing so neither the running clock nor an already
// settled duration is lost when the swap lands.
const replacedTiming =
previousActiveTurnKey !== undefined &&
previousActiveTurnKey !== activeTurnKey &&
!validTurnKeys.has(previousActiveTurnKey) &&
current[activeTurnKey] === undefined
? current[previousActiveTurnKey]
: undefined
let retained = replacedTiming ? { ...current, [activeTurnKey]: replacedTiming } : current
for (const turnKey of Object.keys(retained)) {
if (turnKey !== activeTurnKey && !validTurnKeys.has(turnKey)) {
if (retained === current) {
retained = { ...current }
}
delete (retained as Record<string, NativeChatTurnTiming>)[turnKey]
}
}
const timing = retained[activeTurnKey]
if (isWorking) {
// An in-flight turn keeps the start it already had; only a fresh turn (or an
// authoritative host timestamp) restamps it.
const startedAt =
workingStartedAt ?? (timing && timing.workedSeconds == null ? timing.startedAt : now)
if (timing?.startedAt === startedAt && timing.workedSeconds == null) {
return retained
}
return { ...retained, [activeTurnKey]: { startedAt, workedSeconds: null } }
}
if (timing?.workedSeconds != null) {
return retained
}
const startedAt = timing?.startedAt ?? workingStartedAt
if (startedAt == null) {
return retained
}
return {
...retained,
[activeTurnKey]: {
startedAt,
workedSeconds: Math.max(0, Math.floor((now - startedAt) / 1000))
}
}
}
/** A turn duration the execution host recorded, which outranks anything this
* platform observed locally. */
export type NativeChatSettledTurn = { startedAt: number; workedSeconds: number }
/** Per turn: the host's duration, or null when the host recorded the turn but
* has no duration to show (still running, or its end was never observed).
* Either way the host's word replaces whatever this platform clocked locally. */
export type NativeChatSettledTurns = ReadonlyMap<string, NativeChatSettledTurn | null>
/** Split the timing map into the active turn's status and the settled ones.
* Host-recorded durations override the locally observed ones per turn; local
* observation remains the floor for hosts that record nothing. */
export function selectNativeChatTurnStatuses(
timingByTurn: NativeChatTurnTimingByTurn,
{
activeTurnKey,
isWorking,
workingStartedAt,
thinking,
settledByTurn
}: {
activeTurnKey: string
isWorking: boolean
workingStartedAt?: number | null
/** Whether the active turn is reasoning right now, from its journal content. */
thinking: boolean
settledByTurn?: NativeChatSettledTurns
}
): { active: NativeChatTurnStatus | null; completedByTurn: Record<string, NativeChatTurnStatus> } {
const completedByTurn = Object.fromEntries(
Object.entries(timingByTurn)
.filter(([, timing]) => timing.workedSeconds != null)
.map(([turnKey, timing]) => [
turnKey,
{ startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds }
])
) as Record<string, NativeChatTurnStatus>
for (const [turnKey, settled] of settledByTurn ?? []) {
if (settled === null) {
delete completedByTurn[turnKey]
continue
}
completedByTurn[turnKey] = {
startedAt: settled.startedAt,
thinking: false,
workedSeconds: settled.workedSeconds
}
}
return {
active: isWorking
? {
startedAt: workingStartedAt ?? timingByTurn[activeTurnKey]?.startedAt ?? null,
thinking,
workedSeconds: null
}
: (completedByTurn[activeTurnKey] ?? null),
completedByTurn
}
}
/** Elapsed whole seconds for a counting turn, tolerating a not-yet-stamped start. */
export function nativeChatElapsedSeconds(
startedAt: number | null,
fallbackStartedAt: number,
now: number
): number {
return Math.max(0, Math.floor((now - (startedAt ?? fallbackStartedAt)) / 1000))
}