fix(native-chat): keep the "New" line in place when a turn is expanded (#29230)

This commit is contained in:
Kelvin Amoaba
2026-10-16 02:21:55 +00:00
committed by GitHub
parent 60cf90aec4
commit da27fab595
2 changed files with 111 additions and 49 deletions
@@ -29,9 +29,12 @@ function row(
}
/** The rows as one turn: still `working`, or settled after `worked` seconds, or neither. */
type Turn = { working?: boolean; worked?: number }
type Turn = { working?: boolean; worked?: number; expanded?: boolean }
function slotsOf(messages: NativeChatMessage[], { working = false, worked }: Turn = {}) {
function slotsOf(
messages: NativeChatMessage[],
{ working = false, worked, expanded = false }: Turn = {}
) {
const turnKey = working || worked !== undefined ? 'prompt' : undefined
return buildNativeChatTranscriptSlots({
messages,
@@ -46,7 +49,7 @@ function slotsOf(messages: NativeChatMessage[], { working = false, worked }: Tur
: { prompt: { startedAt: null, thinking: false, workedSeconds: worked } }
},
turnDiffs: new Map(),
expandedTurnKeys: new Set(),
expandedTurnKeys: new Set(expanded && turnKey ? [turnKey] : []),
isWorking: working,
lifecycleWorking: working
})
@@ -54,10 +57,19 @@ function slotsOf(messages: NativeChatMessage[], { working = false, worked }: Tur
const SEEN = [row('prompt', 1, 'user'), row('reply', 2)]
const LATER = [...SEEN, row('answer', 3), row('more', 4)]
/** The reader asks, then the agent works a step and answers. */
const ASKED = [row('prompt', 1, 'user')]
const DONE = [...ASKED, row('step', 2), row('answer', 3)]
const missed = (key: string, after: number, through: number) => ({
key,
after: at(after),
through: at(through)
})
describe('where the "New" marker draws', () => {
it('is above the first row that came while the reader was away', () => {
expect(nativeChatNewRowsMarkerPlacement(slotsOf(LATER), 'answer')).toEqual({
expect(nativeChatNewRowsMarkerPlacement(slotsOf(LATER), missed('answer', 2, 4))).toEqual({
key: 'answer',
place: 'above-row'
})
@@ -66,15 +78,36 @@ describe('where the "New" marker draws', () => {
// The bar's own line would otherwise sit against the marker's.
it("is above the turn bar when only that bar parts the row from the reader's prompt", () => {
const asked = [row('prompt', 1, 'user'), row('answer', 2)]
expect(nativeChatNewRowsMarkerPlacement(slotsOf(asked, { worked: 17 }), 'answer')).toEqual({
key: 'prompt',
place: 'above-turn-bar'
})
expect(
nativeChatNewRowsMarkerPlacement(slotsOf(asked, { worked: 17 }), missed('answer', 1, 2))
).toEqual({ key: 'prompt', place: 'above-turn-bar' })
})
it('stays under the rows of an opened turn the reader had already seen', () => {
expect(
nativeChatNewRowsMarkerPlacement(
slotsOf(DONE, { worked: 17, expanded: true }),
missed('answer', 2, 3)
)
).toEqual({ key: 'answer', place: 'above-row' })
})
// With no prompt of the reader's, the turn's bar draws above its first row, folded or not.
it('is above the bar of a turn the agent opened, whether the turn is open or shut', () => {
const turn = DONE.slice(1)
for (const expanded of [false, true]) {
expect(
nativeChatNewRowsMarkerPlacement(
slotsOf(turn, { worked: 17, expanded }),
missed('answer', 1, 3)
)
).toEqual({ key: 'step', place: 'above-row' })
}
})
it('is absent when nothing came, or the row that did is gone', () => {
expect(nativeChatNewRowsMarkerPlacement(slotsOf(LATER), null)).toBeUndefined()
expect(nativeChatNewRowsMarkerPlacement(slotsOf(SEEN), 'answer')).toBeUndefined()
expect(nativeChatNewRowsMarkerPlacement(slotsOf(SEEN), missed('answer', 2, 4))).toBeUndefined()
})
})
@@ -89,6 +122,7 @@ describe('useNativeChatNewRowsMarker', () => {
messages: NativeChatMessage[]
showing: boolean
waiting?: NativeChatMessage[]
turn?: Turn
}
function chat(messages: NativeChatMessage[], turn?: Turn) {
@@ -96,7 +130,7 @@ describe('useNativeChatNewRowsMarker', () => {
return renderHook(
(props: ChatProps) =>
useNativeChatNewRowsMarker(
slotsOf(props.messages, turn),
slotsOf(props.messages, props.turn ?? turn),
slotsOf(props.waiting ?? []),
props.showing
),
@@ -230,6 +264,23 @@ describe('useNativeChatNewRowsMarker', () => {
expect(view.result.current.marker?.key).toBe('held')
})
it('keeps its place when the reader opens the turn that came while they were away', () => {
const view = leaveAndReturn(ASKED, DONE, { worked: 17 })
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
view.rerender({ messages: DONE, showing: true, turn: { worked: 17, expanded: true } })
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
})
it('stays through another look away after the reader shuts the turn they opened', () => {
const shut = { worked: 17 }
const view = leaveAndReturn(ASKED, DONE, { worked: 17, expanded: true })
view.rerender({ messages: DONE, showing: true, turn: shut })
view.rerender({ messages: DONE, showing: false, turn: shut })
view.rerender({ messages: DONE, showing: true, turn: shut })
expect(view.result.current.marker).toMatchObject({ key: 'prompt', place: 'above-turn-bar' })
})
describe('a reply still being written when the reader left', () => {
const begun = [row('prompt', 1, 'user'), row('reply', 2, 'assistant', 'The plan')]
const grown = [row('prompt', 1, 'user'), row('reply', 2, 'assistant', 'The plan has parts.')]
@@ -5,16 +5,12 @@ import { useCallback, useMemo, useState } from 'react'
import { useOrcaWindowForegroundFocused } from '@/hooks/use-orca-window-foreground-focused'
import { compareAgentJournalPositions } from '../../../../shared/agent-session-journal-position'
import type { AgentJournalPosition } from '../../../../shared/agent-session-journal-types'
import {
nativeChatSlotIndexOf,
nativeChatSlotKey,
type NativeChatTranscriptSlot
} from './native-chat-transcript-slots'
import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots'
/** A drawn row of the conversation itself, as the journal places it. A terminal-backed chat's
* rows carry no position, so it marks nothing. */
* rows carry no position, so it marks nothing. A folded row still draws its turn's bar above. */
function rowPosition(slot: NativeChatTranscriptSlot): AgentJournalPosition | undefined {
return slot.kind === 'message' && slot.depth === 0 && slot.drawsMessage
return slot.kind === 'message' && slot.depth === 0 && (slot.drawsMessage || slot.statusAbove)
? slot.message.journalPosition
: undefined
}
@@ -77,24 +73,38 @@ function nativeChatLeftAt(
return { position: newestRowPosition(all), writing }
}
/** Key of the first row that came, or kept growing, while the reader was away and that they
* did not write, or null. The row, not a position: a queued message is placed again when it
* is handed over. */
function nativeChatFirstNewRow(
/** The first row that came while the reader was away, and the journal stretch it came in. */
export type NativeChatMissedRows = {
/** The row, not a position: a queued message is placed again when it is handed over. */
key: string
after: AgentJournalPosition
/** The newest row on the return: what is written after it was watched arriving. */
through: AgentJournalPosition
}
function isMissedRow(
slot: NativeChatTranscriptSlot,
after: AgentJournalPosition,
through: AgentJournalPosition
): boolean {
const at = rowPosition(slot)
return (
at !== undefined &&
compareAgentJournalPositions(at, after) > 0 &&
compareAgentJournalPositions(at, through) <= 0 &&
(slot.kind !== 'message' || slot.message.role !== 'user' || slot.message.from !== undefined)
)
}
function nativeChatMissedRows(
slots: readonly NativeChatTranscriptSlot[],
{ position, writing }: NativeChatLeftAt
): string | null {
): NativeChatMissedRows | null {
const row = writing && slots.find((slot) => nativeChatSlotKey(slot) === writing.key)
const readTo = writing && !(row && rowSize(row) === writing.size) ? writing.before : position
const first = slots.find((slot) => {
const at = rowPosition(slot)
return (
at !== undefined &&
compareAgentJournalPositions(at, readTo) > 0 &&
(slot.kind !== 'message' || slot.message.role !== 'user' || slot.message.from !== undefined)
)
})
return first ? nativeChatSlotKey(first) : null
const after = writing && !(row && rowSize(row) === writing.size) ? writing.before : position
const through = newestRowPosition(slots)
const first = slots.find((slot) => isMissedRow(slot, after, through))
return first ? { key: nativeChatSlotKey(first), after, through } : null
}
export type NativeChatNewRowsMarkerPlacement = {
@@ -111,16 +121,20 @@ export type NativeChatNewRowsMarkerState = NativeChatNewRowsMarkerPlacement & {
onDismiss: () => void
}
/** Above the row `firstNew` names, or above the turn bar when that bar is all that parts the
* row from the reader's prompt: the two lines would touch. */
/** Above the first missed row, or above the turn bar when only that bar parts the row from the
* reader's prompt: the two lines would touch. */
export function nativeChatNewRowsMarkerPlacement(
slots: readonly NativeChatTranscriptSlot[],
firstNew: string | null
missed: NativeChatMissedRows | null
): NativeChatNewRowsMarkerPlacement | undefined {
if (firstNew === null) {
if (missed === null) {
return undefined
}
const index = nativeChatSlotIndexOf(slots, firstNew)
const { key, after, through } = missed
// Opening a folded turn draws missed rows above the one marked while it was shut.
const index = slots.findIndex(
(slot) => nativeChatSlotKey(slot) === key || isMissedRow(slot, after, through)
)
const first = slots[index]
if (!first) {
return undefined
@@ -147,30 +161,27 @@ export function useNativeChatNewRowsMarker(
watching: boolean
/** While the reader is away: where they had read to. */
left: NativeChatLeftAt | null
/** Key of the first row that came while the reader was away. It stands until they answer. */
firstNew: string | null
/** Stands until the reader answers. */
missed: NativeChatMissedRows | null
arriving: boolean
}>({ watching, left: null, firstNew: null, arriving: false })
}>({ watching, left: null, missed: null, arriving: false })
if (seen.watching !== watching) {
const all = [...slots, ...waitingSlots]
if (watching) {
const firstNew = seen.left ? nativeChatFirstNewRow(all, seen.left) : seen.firstNew
setSeen({ watching, left: null, firstNew, arriving: seen.left !== null && firstNew !== null })
const missed = seen.left ? nativeChatMissedRows(all, seen.left) : seen.missed
setSeen({ watching, left: null, missed, arriving: seen.left !== null && missed !== null })
} else {
// An unanswered marker whose row still stands is kept: the reader may be midway through it.
const marked = seen.firstNew !== null && nativeChatSlotIndexOf(all, seen.firstNew) >= 0
const marked = nativeChatNewRowsMarkerPlacement(all, seen.missed) !== undefined
setSeen({ ...seen, watching, left: marked ? null : nativeChatLeftAt(slots, waitingSlots) })
}
}
const { firstNew } = seen
const placement = useMemo(
() => nativeChatNewRowsMarkerPlacement(slots, firstNew),
[slots, firstNew]
)
const { missed } = seen
const placement = useMemo(() => nativeChatNewRowsMarkerPlacement(slots, missed), [slots, missed])
const dismiss = useCallback(() => {
// Where they left as well: an answer sent before the chat has caught up settles that too.
setSeen((held) =>
held.firstNew === null && held.left === null ? held : { ...held, firstNew: null, left: null }
held.missed === null && held.left === null ? held : { ...held, missed: null, left: null }
)
}, [])
const onArrived = useCallback(() => {