mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
* 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>
260 lines
9.0 KiB
TypeScript
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))
|
|
}
|