fix(mobile): keep the turn clock running when the optimistic echo is replaced

An accepted send renders as `pending-N` until the transcript echo lands under
its real message id. That flips the active turn key mid-turn, and the timing
reducer treated the new key as a new turn — so a turn that had reached
"Working for 8s" visibly restarted at "Working for 0s".

The reducer now carries the start over when the previous key names a turn that
has since left the transcript, which is exactly the echo-replacement case. A
genuinely new turn (the previous key still in the transcript) and a turn that had
already settled both keep their own clock; both are pinned by tests. Desktop does
not pass the new key and is unaffected.
This commit is contained in:
Merge Sim
2026-09-04 19:21:17 -07:00
parent 7756e8abf5
commit 186c8ea5ba
3 changed files with 78 additions and 2 deletions
@@ -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<NativeChatTurnTimingByTurn>({})
// 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<string | undefined>(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
@@ -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<string>(),
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 },
+16 -1
View File
@@ -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<string>
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
}