mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user