diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 08feb9bc7ce..a2f14196e36 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -73,6 +73,7 @@ function MobileNativeChatMessageImpl({ fontScale = 1, onOpenFile, turnStatus, + turnStatusAbove = false, turnExpanded, turnKey, onToggleTurn, @@ -85,8 +86,10 @@ function MobileNativeChatMessageImpl({ /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number onOpenFile?: (relativePath: string) => void - /** This settled turn's status row, rendered under its user message. */ + /** This turn's status row, rendered under its opening user message. */ turnStatus?: NativeChatTurnStatus | null + /** Render the status above the row: its turn has no user bubble of its own. */ + turnStatusAbove?: boolean /** Whether the turn caret has disclosed this turn's activity. */ turnExpanded?: boolean /** Set only when this row's turn has settled and can disclose its activity. */ @@ -127,8 +130,18 @@ function MobileNativeChatMessageImpl({ ? null : (subagentLabel ?? NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed) + const statusRow = turnStatus ? ( + onToggleTurn(turnKey) : undefined} + /> + ) : null return ( <> + {/* A turn with no user bubble carries its bar above its first row. */} + {turnStatusAbove ? statusRow : null} - {turnStatus ? ( - onToggleTurn(turnKey) : undefined} - /> - ) : null} + {turnStatusAbove ? null : statusRow} ) } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 31ce1e22550..00598e6a967 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -77,6 +77,7 @@ export function MobileNativeChatOverlay({ workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} + turnKeysByItemId={controller.nativeChatTurnKeysByItemId} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 1184b126c55..d0a6c39cba6 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -66,8 +66,10 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null - /** Structured lane: the user message the host says opened the running turn. */ + /** Structured lane: the key the host says anchors the running turn's bar. */ activeTurnOpenedBy?: string | null + /** Structured lane: host-attributed turn ownership per journal item id. */ + turnKeysByItemId?: ReadonlyMap | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -154,6 +156,7 @@ export function MobileNativeChatView({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, onStop, streaming, hasMore, @@ -274,6 +277,7 @@ export function MobileNativeChatView({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 686dac407e7..30bf33d7d51 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -39,6 +39,8 @@ export type MobileNativeChatController = { nativeChatSettledTurns: NativeChatSettledTurns | null /** Structured lane: the user message the host says opened the running turn. */ nativeChatActiveTurnOpenedBy: string | null + /** Structured lane: the turn that owns each journal item's row. */ + nativeChatTurnKeysByItemId: ReadonlyMap | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 7a1fa7c468e..c14c3e1251c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -291,6 +291,7 @@ export function useMobileNativeChatController(args: { nativeChatActiveTurnOpenedBy: activeChatStructured ? structuredNativeChat.activeTurnOpenedBy : null, + nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 1757a8ce65b..ed0b22dfc1f 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -22,6 +22,7 @@ function Harness({ settledTurns, workingStartedAt, activeTurnOpenedBy, + turnKeysByItemId, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] @@ -30,6 +31,7 @@ function Harness({ settledTurns?: NativeChatSettledTurns workingStartedAt?: number | null activeTurnOpenedBy?: string | null + turnKeysByItemId?: ReadonlyMap | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -39,6 +41,7 @@ function Harness({ settledTurns, workingStartedAt, activeTurnOpenedBy, + turnKeysByItemId, scopeKey }) return createElement('result', { disclosure }) @@ -240,9 +243,9 @@ describe('useMobileNativeChatTurnDisclosure', () => { let [rowA, rowTool, rowB] = rows() expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) expect(rowB.turnStatus).toBeNull() - // Row liveness still follows the transcript's grouping under the newest prompt. - expect(rowTool.activeTurnIsWorking).toBe(false) - expect(rowB.activeTurnIsWorking).toBe(true) + // Liveness follows the owning turn: A's tool row stays live while B waits. + expect(rowTool.activeTurnIsWorking).toBe(true) + expect(rowB.activeTurnIsWorking).toBe(false) // B's own turn opens: A takes the host's settled duration, B counts from A's end. act(() => { @@ -264,6 +267,144 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) + it("keeps a running turn's rows live across a mid-turn send the host folded in", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + }) + // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. + const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'], + ['B', 'A'], + ['t2', 'A'] + ]) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + activeTurnOpenedBy: 'A', + turnKeysByItemId: owned + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + // The steered bubble shares A's turn but never carries a bar of its own. + expect(rowB.turnStatus).toBeNull() + expect(rowT1.activeTurnIsWorking).toBe(true) + expect(rowT2.activeTurnIsWorking).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("anchors a provider-opened turn's bar above its first row", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const woke: NativeChatMessage = { + id: 'woke', + role: 'assistant', + blocks: [{ type: 'text', text: 'Woke up.' }], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('u1'), woke] + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), + turnKeysByItemId: new Map([ + ['u1', 'u1'], + ['woke', 'wake'] + ]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowU1, rowWoke] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowU1.turnStatus).toBeNull() + expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) + expect(rowWoke.turnStatusAbove).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("never hands a provider-opened turn's clock to a message sent during it", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const row = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + // A wake turn runs; B is sent during it and folded in, so B opened nothing. + const messages = [row('w1'), userMessage('B'), row('w2')] + const owned = new Map([ + ['w1', 'wake'], + ['B', 'wake'], + ['w2', 'wake'] + ]) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + activeTurnOpenedBy: 'wake', + turnKeysByItemId: owned, + settledTurns: new Map([['wake', null]]) + }) + ) + }) + // The wake turn settles: the host no longer names a running turn. + vi.setSystemTime(20_000) + act(() => { + renderer!.update( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + workingStartedAt: null, + activeTurnOpenedBy: null, + turnKeysByItemId: owned, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message)) + expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) + expect(rowB.turnStatus).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 6cbb31c723a..cc61ba63d89 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,6 +1,11 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import { + nativeChatRowTurnKeys, + nativeChatSelfAnchoredTurnRows +} from '../../../src/shared/native-chat-turn-grouping' import { useMobileNativeChatTurnStatus, type NativeChatTurnStatus @@ -8,10 +13,13 @@ import { const EMPTY_TURN_IDS: ReadonlySet = new Set() const EMPTY_TURN_KEYS: readonly undefined[] = [] +const EMPTY_TURN_ANCHORS: ReadonlyMap = new Map() const MAX_EXPANDED_TURNS = 128 export type MobileNativeChatTurnRow = { turnStatus: NativeChatTurnStatus | null + /** The bar renders above the row: its turn has no user bubble of its own. */ + turnStatusAbove?: boolean turnExpanded: boolean /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string @@ -28,6 +36,7 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, thinking = false, activityText = null, scopeKey @@ -38,8 +47,11 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The user message the host says opened the running turn; absent, the latest one. */ + /** The key the host says anchors the running turn's bar; absent, the latest user message. */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership per journal item id; unnamed rows keep + * positional preceding-user grouping. */ + turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -60,6 +72,7 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId, thinking, scopeKey }) @@ -87,52 +100,61 @@ export function useMobileNativeChatTurnDisclosure({ }, [scopeKey] ) - // Resolve each row's turn boundary once — a findLast per row is quadratic on a + // Resolve each row's owning turn once — a findLast per row is quadratic on a // long transcript. - const turnKeys = useMemo(() => { - if (!enabled) { - return EMPTY_TURN_KEYS - } - let turnKey: string | undefined - return messages.map((message) => { - if (message.role === 'user') { - turnKey = message.id - } - return turnKey - }) - }, [enabled, messages]) + const turnKeys = useMemo( + () => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS), + [enabled, messages, turnKeysByItemId] + ) + // A turn with no user bubble (provider-opened) anchors its bar at its first row. + const selfAnchors = useMemo( + () => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS), + [enabled, messages, turnKeys] + ) const { active, activeTurnKey, completedByTurn } = turnStatuses - // Liveness follows the transcript's own grouping (the latest prompt's rows), not the bar's owner. - const latestTurnKey = turnKeys.at(-1) const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const turnKey = turnKeys[index] - // The live turn's bar carries its running clock; it settles in place. - const turnStatus = - !enabled || message.role !== 'user' || !turnKey - ? null - : turnKey === activeTurnKey - ? active - : (completedByTurn[turnKey] ?? null) + const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index + // The live turn's bar carries its running clock; it settles in place. Only + // the bubble that opened a turn carries its bar: a message the provider + // folded into a running turn shares the turn's key but not its bar. + const turnStatus = !enabled + ? null + : message.role === 'user' && message.id === activeTurnKey + ? active + : message.role === 'user' && turnKey === message.id + ? (completedByTurn[turnKey] ?? null) + : anchorsTurnHere + ? turnKey === activeTurnKey + ? (active ?? completedByTurn[turnKey] ?? null) + : (completedByTurn[turnKey] ?? null) + : null return { turnStatus, + ...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, // Why: the key travels and the row calls one stable handler with it. A // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // With no user boundary at all, the session's working state stays authoritative. - activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey + // Liveness is the owning turn's, not the newest prompt's: a running turn's + // rows stay live while a newer message waits behind it. + activeTurnIsWorking: + enabled && + isWorking && + (turnKey === activeTurnKey || + (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) } }, [ turnKeys, + selfAnchors, enabled, activeTurnKey, - latestTurnKey, active, completedByTurn, expandedTurnIds, 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 3dac27e1b25..62f6ada8d5e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -28,6 +28,7 @@ export function useMobileNativeChatTurnStatus({ workingStartedAt, settledTurns, activeTurnOpenedBy = null, + turnKeysByItemId = null, thinking = false, scopeKey }: { @@ -39,6 +40,9 @@ export function useMobileNativeChatTurnStatus({ settledTurns?: NativeChatSettledTurns | null /** The user message the host says opened the running turn; absent, the latest one. */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership; a turn keyed to its own record is still in + * the transcript, so its clock is not an echo to hand to the next prompt. */ + turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -72,6 +76,9 @@ export function useMobileNativeChatTurnStatus({ const validTurnKeys = new Set( messages.filter((message) => message.role === 'user').map((message) => message.id) ) + for (const turnKey of turnKeysByItemId?.values() ?? []) { + validTurnKeys.add(turnKey) + } const previousActiveTurnKey = previousActiveTurn.current?.scopeKey === scopeKey ? previousActiveTurn.current.turnKey @@ -92,7 +99,7 @@ export function useMobileNativeChatTurnStatus({ ? current : { scopeKey, timingByTurn: nextTiming } }) - }, [activeTurnKey, enabled, isWorking, messages, scopeKey, workingStartedAt]) + }, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 3559f6925ed..9181856cc0d 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -12,7 +12,8 @@ import { } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the user message that owns the running turn's bar, and a + * off the journal, the transcript key that owns the running turn's bar, each + * row's owning turn, and a * skew-free start for the live counter whose host-to-local conversion is latched * once per turn. */ export function useMobileStructuredAgentTurnTiming( @@ -30,8 +31,9 @@ export function useMobileStructuredAgentTurnTiming( settledTurns: NativeChatSettledTurns workingStartedAt: number | null activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -48,5 +50,10 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } + return { + settledTurns, + workingStartedAt: step.workingStartedAt, + activeTurnOpenedBy, + turnKeysByItemId + } } diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts new file mode 100644 index 00000000000..8ce2591f57c --- /dev/null +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' +import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' +import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const THREAD_ID = 'thread-abc' + +function historicalTurn(index: number): Record { + const id = `turn-${index}` + return { + id, + status: 'completed', + startedAt: 1_700_000_000 + index * 100, + completedAt: 1_700_000_050 + index * 100, + items: [ + { + type: 'userMessage', + id: `user-${index}`, + content: [{ type: 'text', text: `ask ${index}` }] + }, + { type: 'agentMessage', id: `interim-${index}`, text: `looking ${index}` }, + { type: 'agentMessage', id: `answer-${index}`, text: `answer ${index}` } + ] + } +} + +/** What the journal hands a reader after a restore: rows in append order. */ +function restoredJournal(turnCount: number): AgentJournalRenderItem[] { + const items: AgentJournalRenderItem[] = [] + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => { + const sequence = items.length + 1 + items.push({ + itemId: agentJournalItemKey(identity), + revision: 1, + body, + sequence, + observedAt: sequence + }) + }, + appendTombstone: () => {}, + publish: () => {} + }, + sessionId: 'session-1', + primaryThreadId: () => THREAD_ID + }) + const turns = Array.from({ length: turnCount }, (_, index) => historicalTurn(index + 1)) + expect(translator.restoreThread(THREAD_ID, { turns })).toEqual({ accepted: true }) + return items +} + +describe('grouping a Codex thread restored from full history', () => { + it('keeps each turn with its own rows and folds each to its own answer', () => { + const items = restoredJournal(3) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] + ]) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 7005c10a43b..5dc68d6f764 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -59,6 +59,17 @@ export function restoreCodexJournalThread(input: { if (!turnId) { continue } + // Ahead of the turn's items, as the live path writes it: readers credit every + // row to the nearest turn record before it. + const lifecycle = input.restoreTurnLifecycle + ? historicalTurnLifecycle(input.threadId, turn) + : null + if (lifecycle) { + const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } + if (!admission.accepted) { + return admission + } + } input.currentTurnIds.set(input.threadId, new Set([turnId])) for (const item of Array.isArray(turn.items) ? turn.items : []) { const admission = input.handleItem({ @@ -72,15 +83,6 @@ export function restoreCodexJournalThread(input: { } input.currentTurnIds.delete(input.threadId) input.ordinals.forgetTurn(input.threadId, turnId) - const lifecycle = input.restoreTurnLifecycle - ? historicalTurnLifecycle(input.threadId, turn) - : null - if (lifecycle) { - const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true } - if (!admission.accepted) { - return admission - } - } } input.flush() return { accepted: true } diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 62aa5f828a1..6fb589caf4e 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -533,8 +533,8 @@ describe('codex turn lifecycle rows', () => { }) ).toEqual({ accepted: true }) + // Each record precedes its turn's items, the order the live path writes. expect(tap.rows).toEqual([ - expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done', body: { @@ -548,6 +548,7 @@ describe('codex turn lifecycle rows', () => { durationMs: 41_900 } }, + expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }), { key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut', body: { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d739e0b8d61..ee697732671 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -47,6 +47,7 @@ import { selectNativeChatActiveTurnKey, type NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -75,6 +76,7 @@ export function NativeChatMessageList({ workingStartedAt, settledTurns, activeTurnOpenedBy, + turnKeysByItemId = null, deliveryNotices, showTurnStatus = true, showLiveTurnActivity = true, @@ -94,8 +96,11 @@ export function NativeChatMessageList({ workingStartedAt?: number | null /** Host-recorded turn durations keyed by user message id (structured lane). */ settledTurns?: NativeChatSettledTurns - /** The user message the host says opened the running turn (structured lane). */ + /** The key the host says anchors the running turn's bar (structured lane). */ activeTurnOpenedBy?: string | null + /** Host-attributed turn ownership per journal item id (structured lane). + * Rows it does not name keep positional preceding-user grouping. */ + turnKeysByItemId?: ReadonlyMap | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap @@ -160,18 +165,12 @@ export function NativeChatMessageList({ const showTypingIndicator = showTurnStatus ? isWorking : shouldShowNativeChatTypingIndicator({ messages, isWorking }) - const currentTurnKey = messages.findLast((message) => message.role === 'user')?.id ?? undefined - // Resolve each row's turn boundary once. Prefix slice/findLast in the render + // Resolve each row's owning turn once. Prefix slice/findLast in the render // loop becomes quadratic for long transcripts. - const turnKeys = useMemo(() => { - let currentTurnKey: string | undefined - return messages.map((message) => { - if (message.role === 'user') { - currentTurnKey = message.id - } - return currentTurnKey - }) - }, [messages]) + const turnKeys = useMemo( + () => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null), + [messages, showTurnStatus, turnKeysByItemId] + ) const turnDiffs = useMemo( () => journalItems @@ -201,7 +200,6 @@ export function NativeChatMessageList({ messages, turnKeys, activeTurnKey, - currentTurnKey, receipts, turnStatuses, turnDiffs, @@ -213,7 +211,6 @@ export function NativeChatMessageList({ }), [ activeTurnKey, - currentTurnKey, expandedTurnIds, isWorking, lifecycleWorking, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx index b80d963bb25..635eef94c35 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx @@ -140,6 +140,44 @@ describe('NativeChatMessageList settled turn fold', () => { expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull() }) + // The #23621 shape: the host folded a mid-turn send into the running turn, so + // the rows after it are still the opener's. One bar under the opener folds + // them all; the steered bubble stays visible and never grows a bar or group. + it("folds the rows after a mid-turn send behind the opener's bar", () => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1'])) + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() + }) + // The answer is the LAST prose the agent produced. A turn whose final output // is a tool run still answers with the prose before it. it('keeps the last prose row when the turn ends on tool activity', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2de5591b779..26f04ae2e3c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -280,6 +280,7 @@ export function NativeChatStructuredSession( workingStartedAt={controller.workingStartedAt} settledTurns={controller.settledTurns} activeTurnOpenedBy={controller.activeTurnOpenedBy} + turnKeysByItemId={controller.turnKeysByItemId} showTurnStatus showLiveTurnActivity={prompt === null} turnActivity={controller.turnActivity} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index e5313d24d87..54499e3dc31 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -43,8 +43,20 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ const { message, turnKey, status, receipt, turnDiff } = slot const predecessors = context.taskListPredecessors.get(message.id) const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined + const statusRow = status ? ( + context.onToggleExpandedTurn(turnKey) : undefined + } + /> + ) : null return (
+ {/* A turn with no user bubble carries its bar above its first row. */} + {slot.statusAbove ? statusRow : null} {receipt ? ( ) : ( @@ -68,16 +80,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ runtimeContext={context.runtimeContext} /> )} - {status ? ( - context.onToggleExpandedTurn(turnKey) : undefined - } - /> - ) : null} + {slot.statusAbove ? null : statusRow} {turnDiff ? ( ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 3e760a787ca..fa4995b4755 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -44,7 +44,6 @@ function slotsOf(messages: NativeChatMessage[]) { messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 5ee40345e1b..ba8e6fa8f21 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -83,7 +83,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: turn, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 5f1fc09b3a1..21e48166e9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -40,7 +40,6 @@ function build( messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), @@ -208,6 +207,113 @@ describe('a send the host rejected', () => { }) }) +// Rows belong to the turn the host says owns them, not to the nearest preceding +// user bubble. The owned turn keys reshape the fold, the bar and liveness. +describe('turn-owned grouping', () => { + const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 70 } + // The #23621 shape: B lands mid-turn, three tool calls follow, one turn. + const midTurn = [ + text('A', 'go', 'user'), + toolRun('t1'), + text('B', 'and also this', 'user'), + toolRun('t2'), + toolRun('t3'), + toolRun('t4'), + text('answer', 'Done.') + ] + const ownedKeys = midTurn.map(() => 'A') + + it("folds every row of a settled turn behind its opener's bar, across a mid-turn send", () => { + const slots = build(midTurn, { + turnKeys: ownedKeys, + turnStatuses: { active: null, completedByTurn: { A: settled } } + }) + // All four tool rows fold; the steered bubble stays visible with no bar of its own. + expect(slots.map((slot) => [slot.message.id, slot.status ?? null])).toEqual([ + ['A', settled], + ['B', null], + ['answer', null] + ]) + expect(slots[0]?.turnFolds).toBe(true) + }) + + it('returns every row of the turn when the reader opens it', () => { + const slots = build(midTurn, { + turnKeys: ownedKeys, + turnStatuses: { active: null, completedByTurn: { A: settled } }, + expandedTurnKeys: new Set(['A']) + }) + expect(slots.map((slot) => slot.message.id)).toEqual([ + 'A', + 't1', + 'B', + 't2', + 't3', + 't4', + 'answer' + ]) + }) + + it("anchors a provider-opened turn's bar above its first row", () => { + const messages = [text('u1', 'earlier', 'user'), toolRun('w1'), text('done', 'Woke up.')] + const slots = build(messages, { + turnKeys: ['u1', 'wake', 'wake'], + activeTurnKey: 'wake', + turnStatuses: { active: settled, completedByTurn: {} }, + isWorking: true + }) + expect(slots.map((slot) => [slot.message.id, slot.status ?? null, slot.statusAbove])).toEqual([ + ['u1', null, false], + ['w1', settled, true], + ['done', null, false] + ]) + }) + + it("rolls a turn's diff up once, under its last row, when another prompt lands among its rows", () => { + // B opens its own turn but was sent before A's last row was written. + const messages = [ + text('A', 'go', 'user'), + toolRun('t1'), + text('B', 'next', 'user'), + text('a-answer', 'Done with A.'), + toolRun('b1') + ] + const diff = (added: number): NativeChatTurnDiff => ({ + files: [], + added, + removed: 0, + truncated: false + }) + const slots = build(messages, { + turnKeys: ['A', 'A', 'B', 'A', 'B'], + turnDiffs: new Map([ + ['A', diff(1)], + ['B', diff(2)] + ]) + }) + expect( + slots.filter((slot) => slot.turnDiff).map((slot) => [slot.message.id, slot.turnDiff?.added]) + ).toEqual([ + ['a-answer', 1], + ['b1', 2] + ]) + }) + + it("keeps a running turn's rows live while a newer message waits behind it", () => { + const messages = [text('A', 'go', 'user'), toolRun('t1'), text('C', 'next up', 'user')] + const slots = build(messages, { + turnKeys: ['A', 'A', 'C'], + activeTurnKey: 'A', + isWorking: true + }) + expect(slots.map((slot) => [slot.message.id, slot.activeTurnIsWorking])).toEqual([ + ['A', true], + ['t1', true], + ['C', false] + ]) + }) +}) + describe("a subagent's rows speak as that subagent", () => { const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 5 } const roster: NativeChatMessage = { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 6e0dc5bff4b..1b20671ce24 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -15,7 +15,11 @@ import { } from '../../../../shared/native-chat-types' import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution' -import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { + NATIVE_CHAT_UNANCHORED_TURN_KEY, + type NativeChatTurnStatus +} from '../../../../shared/native-chat-turn-status' +import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, type NativeChatTurnFoldRow @@ -44,6 +48,9 @@ export type NativeChatTranscriptSlot = { receipt: NativeChatResolvedPrompt | undefined /** Turn timing shown under this row, already filtered to "should render". */ status: NativeChatTurnStatus | undefined + /** The bar renders above the row: this turn has no user bubble of its own + * (provider-opened), so its bar sits at the turn's position instead. */ + statusAbove?: boolean /** This row is behind its turn's folded status row: it draws no prose and no * tool activity, only work that outlives the turn. */ folded: boolean @@ -60,9 +67,8 @@ export type NativeChatTranscriptSlot = { export type NativeChatTranscriptSlotsInput = { messages: readonly NativeChatMessage[] turnKeys: readonly (string | undefined)[] - /** The user message whose bar carries the live turn's status. */ + /** The transcript key whose bar carries the live turn's status. */ activeTurnKey: string - currentTurnKey: string | undefined receipts: ReadonlyMap turnStatuses: { active: NativeChatTurnStatus | null @@ -86,7 +92,6 @@ export function buildNativeChatTranscriptSlots( messages, turnKeys, activeTurnKey, - currentTurnKey, receipts, turnStatuses, turnDiffs, @@ -147,19 +152,37 @@ export function buildNativeChatTranscriptSlots( settledTurnKeys, expandedTurnKeys }) + // A turn with no user bubble (provider-opened) anchors its bar at its first row. + const selfAnchors = nativeChatSelfAnchoredTurnRows(messages, turnKeys) + // A turn's rows need not be contiguous (another turn's prompt can land among + // them), so its rollup goes under its last row, not every run boundary. + const lastRowByTurn = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined) { + lastRowByTurn.set(turnKey, index) + } + }) const slots: NativeChatTranscriptSlot[] = [] for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] const receipt = receipts.get(message.id) + const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index + // Only the bubble that opened a turn carries its bar: a message the provider + // folded into a running turn shares the turn's key but not its bar. const candidateStatus = message.role === 'user' && message.id === activeTurnKey ? turnStatuses.active - : message.role === 'user' && turnKey + : message.role === 'user' && turnKey === message.id ? turnStatuses.completedByTurn[turnKey] - : undefined + : anchorsTurnHere + ? turnKey === activeTurnKey + ? (turnStatuses.active ?? turnStatuses.completedByTurn[turnKey]) + : turnStatuses.completedByTurn[turnKey] + : undefined // The live turn's bar carries its running clock; it settles in place. const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined - const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + const turnDiff = + turnKey && lastRowByTurn.get(turnKey) === index ? turnDiffs.get(turnKey) : undefined const folded = foldedRows.has(index) // Skipping a folded row entirely is what keeps windowing honest: a counted // index the row declines to draw reserves estimated height for nothing and @@ -173,12 +196,16 @@ export function buildNativeChatTranscriptSlots( slots.push({ message, turnKey, + // Liveness is the owning turn's, not the newest prompt's: a running turn's + // rows stay live while a newer message waits behind it. activeTurnIsWorking: - (currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) && + (turnKey === activeTurnKey || + (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) && (isWorking || lifecycleWorking), trailingRun: trailingRunIndexes.has(index), receipt, status: status ?? undefined, + statusAbove: anchorsTurnHere && status !== undefined, folded, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index b48025533f4..cbbd819ce22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -33,7 +33,6 @@ function slotsOf(messages: NativeChatMessage[]) { messages, turnKeys, activeTurnKey: selectNativeChatActiveTurnKey(messages), - currentTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index d9ca4995b13..620844881f9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -157,6 +157,7 @@ export function useStructuredAgentSession(args: { workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy, + turnKeysByItemId: transportState.turnTiming.turnKeysByItemId, turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index 1c26fd9750c..dd6b1662824 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -12,7 +12,8 @@ import { } from '../../../../shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the user message that owns the running turn's bar, and a + * off the journal, the transcript key that owns the running turn's bar, each + * row's owning turn, and a * skew-free start for the live counter whose host-to-local conversion is latched * once per turn. */ export function useStructuredAgentTurnTiming( @@ -30,8 +31,9 @@ export function useStructuredAgentTurnTiming( settledTurns: NativeChatSettledTurns workingStartedAt: number | null activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo( + const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -48,5 +50,10 @@ export function useStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy } + return { + settledTurns, + workingStartedAt: step.workingStartedAt, + activeTurnOpenedBy, + turnKeysByItemId + } } diff --git a/src/shared/native-chat-turn-grouping.test.ts b/src/shared/native-chat-turn-grouping.test.ts new file mode 100644 index 00000000000..5c6425dd7ed --- /dev/null +++ b/src/shared/native-chat-turn-grouping.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { nativeChatRowTurnKeys, nativeChatSelfAnchoredTurnRows } from './native-chat-turn-grouping' + +function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } +} + +describe('nativeChatRowTurnKeys', () => { + it('keeps rows after a mid-turn send with the turn that produced them', () => { + // The #23621 shape: B lands mid-turn, three tool rows follow, all one turn. + const messages = [ + message('A', 'user'), + message('t1'), + message('B', 'user'), + message('t2'), + message('t3'), + message('t4') + ] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'], + ['B', 'A'], + ['t2', 'A'], + ['t3', 'A'], + ['t4', 'A'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'A', 'A', 'A', 'A']) + }) + + it('lets an unmapped user row key itself and unmapped rows inherit it', () => { + // C is an optimistic echo the journal has not admitted yet: positional rules. + const messages = [message('A', 'user'), message('t1'), message('C', 'user'), message('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'A'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'C', 'C']) + }) + + it('reproduces preceding-user grouping exactly when the host attributes nothing', () => { + const messages = [ + message('lead'), + message('A', 'user'), + message('t1'), + message('B', 'user'), + message('t2') + ] + const positional = [undefined, 'A', 'A', 'B', 'B'] + expect(nativeChatRowTurnKeys(messages, null)).toEqual(positional) + expect(nativeChatRowTurnKeys(messages, new Map())).toEqual(positional) + }) + + it('keys a provider-opened turn to a record no message carries', () => { + const messages = [message('A', 'user'), message('t1'), message('t2')] + const owned = new Map([ + ['A', 'A'], + ['t1', 'wake-turn'], + ['t2', 'wake-turn'] + ]) + expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'wake-turn', 'wake-turn']) + }) +}) + +describe('nativeChatSelfAnchoredTurnRows', () => { + it('anchors a turn with no user bubble at its first rendered row', () => { + const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')] + const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn'] + expect(nativeChatSelfAnchoredTurnRows(messages, turnKeys)).toEqual(new Map([['wake-turn', 2]])) + }) + + it('never anchors a turn whose key is a rendered message', () => { + const messages = [message('A', 'user'), message('t1')] + expect(nativeChatSelfAnchoredTurnRows(messages, ['A', 'A']).size).toBe(0) + }) +}) diff --git a/src/shared/native-chat-turn-grouping.ts b/src/shared/native-chat-turn-grouping.ts new file mode 100644 index 00000000000..7c181352db5 --- /dev/null +++ b/src/shared/native-chat-turn-grouping.ts @@ -0,0 +1,49 @@ +// Which turn each transcript row belongs to, and where a turn with no user +// bubble anchors its bar. Shared because desktop and mobile both group rows and +// place bars from these keys, and a row grouped differently on each surface is +// the same bug twice. + +import type { NativeChatMessage } from './native-chat-types' + +/** + * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`, from + * `selectStructuredAgentTurnBars`) wins, so rows after a mid-turn send stay with + * the turn that produced them. Rows it cannot name keep positional grouping — + * an unmapped user row keys itself, anything else inherits the previous row's + * key — which on a host without turn attribution is exactly today's + * preceding-user-message grouping. + */ +export function nativeChatRowTurnKeys( + messages: readonly NativeChatMessage[], + turnKeysByItemId?: ReadonlyMap | null +): (string | undefined)[] { + let currentTurnKey: string | undefined + return messages.map((message) => { + const owned = turnKeysByItemId?.get(message.id) + if (owned !== undefined) { + currentTurnKey = owned + return owned + } + if (message.role === 'user') { + currentTurnKey = message.id + } + return currentTurnKey + }) +} + +/** For each turn key with no message of its own — a turn the provider opened, or + * whose opener is outside the loaded window — the row index that anchors its + * bar: the turn's first rendered row, matching the turn's own position. */ +export function nativeChatSelfAnchoredTurnRows( + messages: readonly NativeChatMessage[], + turnKeys: readonly (string | undefined)[] +): ReadonlyMap { + const messageIds = new Set(messages.map((message) => message.id)) + const anchors = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined && !messageIds.has(turnKey) && !anchors.has(turnKey)) { + anchors.set(turnKey, index) + } + }) + return anchors +} diff --git a/src/shared/structured-agent-session-turn-bars.test.ts b/src/shared/structured-agent-session-turn-bars.test.ts index 0f4732a3aac..219939d00e6 100644 --- a/src/shared/structured-agent-session-turn-bars.test.ts +++ b/src/shared/structured-agent-session-turn-bars.test.ts @@ -333,7 +333,7 @@ describe('turns whose host names no opener', () => { expect([...selectStructuredAgentSettledTurns(items).keys()]).toEqual(['orca:u1']) }) - it('falls back for a turn the provider opened and keyed to itself', () => { + it('anchors a turn the provider opened to its own record, never a bystander prompt', () => { const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed' const items = [ user(1, 'u1', 1_000), @@ -344,7 +344,37 @@ describe('turns whose host names no opener', () => { userItemId: self }) ] - expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBeNull() + expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBe(self) + }) + + it('keeps the running bar on the send Codex opened a turn for before it echoes it', () => { + // Codex reports turn/started before hooks and prewarm run, so the turn names its + // provider key while the send that opened it is still pending (no alias yet). + const key = 'codex:thread:t2:0' + const items: AgentJournalRenderItem[] = [ + user(1, 'first', 1_000), + turn(2, 1_100, { + turnId: 't1', + state: 'completed', + userItemId: 'orca:first', + startedAt: 1_100, + completedAt: 2_000 + }), + tool(3, 1_500), + user(4, 'second', 3_000), + turn(5, 3_100, { turnId: 't2', state: 'running', startedAt: 3_100, userItemId: key }) + ] + const submissions = [accepted('first', 'claude:first'), accepted('second', null)] + const bars = selectStructuredAgentTurnBars(items, submissions, 't2') + expect(bars.activeTurnOpenedBy).toBe('orca:second') + expect(bars.turnKeysByItemId.get('orca:second')).toBe('orca:second') + // A turn with no send in flight still anchors to its own record. + const selfOpened = selectStructuredAgentTurnBars( + items, + [accepted('first', 'claude:first'), accepted('second', 'claude:second')], + 't2' + ) + expect(selfOpened.activeTurnOpenedBy).toBe('legacy:claude:55368cfb:turn-lifecycle%3At2') }) it('names nothing for the unanchored transcript', () => { @@ -399,3 +429,104 @@ describe('the previous turn has no recorded end', () => { expect(structuredAgentTurnOrigin(queued(12_000))).toBe(9_000) }) }) + +describe('which turn owns each transcript row', () => { + const keysOf = (snapshot: { + turnId: string | null + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + }) => + selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, snapshot.turnId) + .turnKeysByItemId + + it('keeps the rows Claude produces after a mid-turn send with the turn that ran them', () => { + // The #23621 shape: B lands mid-turn and three tool calls follow, one turn. + const midTurn = { + turnId: turn1.turnId, + items: [ + user(54, 'A', A.sent), + turn(57, T + 77_224, { + ...turn1, + state: 'running', + startedAt: A.started, + requestedAt: A.sent + }), + tool(58, T + 77_300), + user(60, 'B', B.sent), + tool(61, T + 80_100), + tool(62, T + 80_200), + tool(63, T + 80_300) + ], + submissions: [accepted('A', turn1.userItemId), accepted('B', null)] + } + const keys = keysOf(midTurn) + expect(keys.get('orca:A')).toBe('orca:A') + expect(keys.get('orca:B')).toBe('orca:A') + for (const sequence of [58, 61, 62, 63]) { + expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + } + }) + + it('leaves a fresh tail send unowned until the turn proves it continued past it', () => { + // B is the newest row: nothing after it says the running turn absorbed it. + expect(keysOf(whileQueued).has('orca:B')).toBe(false) + // B's own turn opened: B is an opener and keys itself. + expect(keysOf(whileB).get('orca:B')).toBe('orca:B') + expect(keysOf(whileB).get(`orca:claude-tool%3A58`)).toBe('orca:A') + }) + + it('folds a Codex-coalesced send into the turn its provider key names', () => { + const key = 'codex:thread:t1:0' + const items: AgentJournalRenderItem[] = [ + user(1, 'first', 1_000), + { + itemId: 'legacy:codex:s:turn-lifecycle%3At1', + revision: 1, + sequence: 2, + observedAt: 1_200, + body: { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: key, + requestedAt: 1_000, + startedAt: 1_200 + } + }, + user(3, 'second', 4_000), + tool(4, 5_000) + ] + const submissions = [accepted('first', key), accepted('second', key)] + const keys = keysOf({ turnId: 't1', items, submissions }) + expect(keys.get('orca:first')).toBe('orca:first') + expect(keys.get('orca:second')).toBe('orca:first') + expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + }) + + it('keys a provider-opened turn and its rows to the turn record itself', () => { + const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed' + const items = [ + user(1, 'u1', 1_000), + turn(2, 5_000, { + turnId: 'resumed', + state: 'running', + startedAt: 5_000, + userItemId: self + }), + tool(3, 6_000) + ] + const keys = keysOf({ turnId: 'resumed', items, submissions: [] }) + // u1 predates the wake turn and never opened one: positional grouping keeps it. + expect(keys.has('orca:u1')).toBe(false) + expect(keys.get('orca:claude-tool%3A3')).toBe(self) + }) + + it('attributes nothing for an older host that names no opener', () => { + const items = [ + user(1, 'u1', 1_000), + turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), + tool(3, 1_200) + ] + expect(keysOf({ turnId: 't1', items, submissions: [] }).size).toBe(0) + }) +}) diff --git a/src/shared/structured-agent-session-turn-timing.test.ts b/src/shared/structured-agent-session-turn-timing.test.ts index ba9af94258f..1cf2d32db91 100644 --- a/src/shared/structured-agent-session-turn-timing.test.ts +++ b/src/shared/structured-agent-session-turn-timing.test.ts @@ -226,7 +226,7 @@ describe('explicit user-item attribution', () => { expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual(['claude:s:u1']) }) - it('attributes nothing when a keyed row names a user item nobody journaled', () => { + it('anchors to the record itself when a keyed row names a user item nobody journaled', () => { const items = [ user('orca:first'), lifecycle('auto', { @@ -236,7 +236,11 @@ describe('explicit user-item attribution', () => { completedAt: 2_000 }) ] - expect(selectStructuredAgentTurnTimings(items).size).toBe(0) + // Never the preceding prompt, which did not open this turn; the record's own + // key gives the turn a bar at its own position instead. + expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual([ + 'legacy:codex:s:turn-lifecycle%3Aauto' + ]) }) it('falls back to journal order only for rows without a key (older hosts)', () => { diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 9d93639e181..e760796618c 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -75,9 +75,12 @@ function readTiming( /** One turn record, read in journal order. */ type StructuredAgentJournalTurn = { timing: StructuredAgentTurnTiming | null - /** The user message the host names as the turn's opener; null when it names - * none (an older host, or a turn the provider opened and keyed to itself). */ - openedBy: string | null + /** The transcript key that anchors this turn's bar and owns its rows: the + * opener user item when the host names one it can resolve (or the send still + * in flight ahead of the record), else the turn record's own item (a turn the + * provider opened, or an opener outside the loaded window). Null only for an + * older host that names nothing. */ + key: string | null } type StructuredAgentJournalTurns = { @@ -105,13 +108,24 @@ function readStructuredAgentJournalTurns( aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) } } + // Sends not yet matched to a provider item. Codex reports a turn open before it + // echoes the send, so for that gap the turn names a key no alias resolves yet. + const inFlight = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) const byUserItem = new Map() const byTurnId = new Map() let precedingUserItemId: string | null = null + let inFlightSinceLastTurn: string | null = null let precedingTurnEndedAt: number | undefined for (const item of items) { if (item.body.kind === 'message' && item.body.role === 'user') { precedingUserItemId = item.itemId + if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { + inFlightSinceLastTurn = item.itemId + } continue } const turn = readAgentJournalTurn(item.body) @@ -122,14 +136,16 @@ function readStructuredAgentJournalTurns( precedingTurnEndedAt = timing?.completedAt ?? item.observedAt const key = turn.userItemId const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null) - byTurnId.set(turn.turnId, { - timing, - openedBy: named === item.itemId ? null : named - }) + // An unresolved opener is the send still in flight ahead of this record; with none, + // it is outside the window, and the turn anchors to its own record like a + // provider-opened one — never to a preceding prompt that did not open it. + const turnKey = key === undefined ? null : (named ?? inFlightSinceLastTurn ?? item.itemId) + inFlightSinceLastTurn = null + byTurnId.set(turn.turnId, { timing, key: turnKey }) if (!timing && turn.state !== 'unverifiable') { continue } - const userItemId = key === undefined ? precedingUserItemId : named + const userItemId = key === undefined ? precedingUserItemId : turnKey if (userItemId !== null) { byUserItem.set(userItemId, timing) } @@ -137,6 +153,58 @@ function readStructuredAgentJournalTurns( return { byUserItem, byTurnId } } +/** + * Which turn owns each journal item, as the transcript groups rows: every item + * between a turn record and the next belongs to that record's turn (the record + * is appended when the turn opens and revised in place, and an opener's user + * item is written ahead of dispatch, so journal order is turn order). A user + * item that opened any turn — even a later one it queued for — keys itself; one + * the provider folded into a running turn (a steer) takes that turn's key, but + * only once the turn produces more rows after it, so a fresh tail send is not + * pulled into the turn it is merely waiting behind. Items before the first turn + * record stay absent, and the surface keeps its positional grouping for them. + */ +function turnKeysByItemIdOf( + items: readonly AgentJournalRenderItem[], + byTurnId: ReadonlyMap +): ReadonlyMap { + const openers = new Set() + for (const turn of byTurnId.values()) { + if (turn.key !== null) { + openers.add(turn.key) + } + } + const keys = new Map() + let currentKey: string | null = null + // User items folded into the current turn, held until a later row proves the + // turn continued past them. + let pendingUserItemIds: string[] = [] + for (const item of items) { + const turn = readAgentJournalTurn(item.body) + if (turn) { + currentKey = byTurnId.get(turn.turnId)?.key ?? null + pendingUserItemIds = [] + continue + } + if (item.body.kind === 'message' && item.body.role === 'user') { + if (openers.has(item.itemId)) { + keys.set(item.itemId, item.itemId) + } else if (currentKey !== null) { + pendingUserItemIds.push(item.itemId) + } + continue + } + if (currentKey !== null) { + for (const userItemId of pendingUserItemIds) { + keys.set(userItemId, currentKey) + } + pendingUserItemIds = [] + keys.set(item.itemId, currentKey) + } + } + return keys +} + export function selectStructuredAgentTurnTimings( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] @@ -237,8 +305,9 @@ function settledTurnsOf( } /** Everything a chat surface reads off the journal for its turn bars, from one - * pass: settled durations, the running turn's timing, and the user message that - * owns its bar — which a message sent while the turn runs is not. */ + * pass: settled durations, the running turn's timing, the item that anchors its + * bar — which a message sent while the turn runs is not — and which turn owns + * each row of the transcript. */ export function selectStructuredAgentTurnBars( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], @@ -246,13 +315,18 @@ export function selectStructuredAgentTurnBars( ): { settledTurns: NativeChatSettledTurns runningTiming: StructuredAgentTurnTiming | null + /** The transcript key the running turn's bar anchors to: its opening user + * message, or the turn record itself when the provider opened the turn. + * Null on an older host, where the newest user message stays the anchor. */ activeTurnOpenedBy: string | null + turnKeysByItemId: ReadonlyMap } { const turns = readStructuredAgentJournalTurns(items, submissions) const running = turnId === null ? undefined : turns.byTurnId.get(turnId) return { settledTurns: settledTurnsOf(turns, submissions), runningTiming: running?.timing ?? null, - activeTurnOpenedBy: running?.openedBy ?? null + activeTurnOpenedBy: running?.key ?? null, + turnKeysByItemId: turnKeysByItemIdOf(items, turns.byTurnId) } }