diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 319c11f3dd5..f11b77ff173 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { nativeChatTurnHasResponse, @@ -32,6 +32,9 @@ export function useMobileNativeChatTurnStatus({ const latestUserId = latestUserIndex !== -1 ? (messages[latestUserIndex]?.id ?? null) : null const activeTurnKey = latestUserId ?? MOBILE_UNANCHORED_TURN_KEY const [timingByTurn, setTimingByTurn] = useState({}) + // An accepted send renders as `pending-N` until the transcript echo lands under + // its real id. That is one turn under two keys, so the clock must survive the swap. + const previousActiveTurnKey = useRef(undefined) useEffect(() => { const validTurnKeys = new Set( @@ -40,12 +43,14 @@ export function useMobileNativeChatTurnStatus({ setTimingByTurn((current) => reduceNativeChatTurnTiming(current, { activeTurnKey, + previousActiveTurnKey: previousActiveTurnKey.current, validTurnKeys, isWorking, workingStartedAt, now: Date.now() }) ) + previousActiveTurnKey.current = activeTurnKey }, [activeTurnKey, isWorking, messages, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index b3010b96d81..e83a586e0c2 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -186,6 +186,62 @@ describe('reduceNativeChatTurnTiming', () => { ).toEqual({}) }) + it('carries the elapsed start across an optimistic echo becoming a transcript row', () => { + // The mobile composer renders an accepted send as `pending-N` until the + // transcript echo lands under its real id. Without the carry-over the active + // turn key flips mid-turn and "Working for 8s" restarts at 0s. + const working = reduceNativeChatTurnTiming( + {}, + { + activeTurnKey: 'pending-1', + validTurnKeys: new Set(), + isWorking: true, + now: 1_000 + } + ) + expect(working['pending-1']?.startedAt).toBe(1_000) + const swapped = reduceNativeChatTurnTiming(working, { + activeTurnKey: 'u9', + previousActiveTurnKey: 'pending-1', + validTurnKeys: new Set(['u9']), + isWorking: true, + now: 9_000 + }) + expect(swapped.u9).toEqual({ startedAt: 1_000, workedSeconds: null }) + expect(swapped['pending-1']).toBeUndefined() + }) + + it('does not carry the start into a genuinely new turn', () => { + // The previous turn is still in the transcript, so this is the user sending + // again — that turn starts its own clock. + const working = reduceNativeChatTurnTiming( + {}, + { activeTurnKey: 'u1', validTurnKeys: new Set(['u1']), isWorking: true, now: 1_000 } + ) + const next = reduceNativeChatTurnTiming(working, { + activeTurnKey: 'u2', + previousActiveTurnKey: 'u1', + validTurnKeys: new Set(['u1', 'u2']), + isWorking: true, + now: 9_000 + }) + expect(next.u2?.startedAt).toBe(9_000) + }) + + it('does not carry a start from a turn that had already settled', () => { + const settled: NativeChatTurnTimingByTurn = { + 'pending-1': { startedAt: 1_000, workedSeconds: 5 } + } + const next = reduceNativeChatTurnTiming(settled, { + activeTurnKey: 'u9', + previousActiveTurnKey: 'pending-1', + validTurnKeys: new Set(['u9']), + isWorking: true, + now: 9_000 + }) + expect(next.u9?.startedAt).toBe(9_000) + }) + it('drops timings for turns that left the transcript, keeping the active one', () => { const current: NativeChatTurnTimingByTurn = { gone: { startedAt: 1, workedSeconds: 2 }, diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 4dd898bd23f..6d499060225 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -99,18 +99,32 @@ 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 isWorking: boolean workingStartedAt?: number | null now: number } ): NativeChatTurnTimingByTurn { + const replacedTiming = + previousActiveTurnKey !== undefined && + previousActiveTurnKey !== activeTurnKey && + !validTurnKeys.has(previousActiveTurnKey) + ? current[previousActiveTurnKey] + : undefined + const carriedStartedAt = + replacedTiming?.workedSeconds == null ? replacedTiming?.startedAt : undefined let retained = current for (const turnKey of Object.keys(current)) { if (turnKey !== activeTurnKey && !validTurnKeys.has(turnKey)) { @@ -126,7 +140,8 @@ export function reduceNativeChatTurnTiming( // 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) + workingStartedAt ?? + (timing && timing.workedSeconds == null ? timing.startedAt : (carriedStartedAt ?? now)) if (timing?.startedAt === startedAt && timing.workedSeconds == null) { return retained }