diff --git a/src/renderer/src/components/native-chat/native-chat-new-rows-marker.test.tsx b/src/renderer/src/components/native-chat/native-chat-new-rows-marker.test.tsx index 063b444e718..8f6e3e00e73 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-rows-marker.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-new-rows-marker.test.tsx @@ -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.')] diff --git a/src/renderer/src/components/native-chat/native-chat-new-rows-marker.ts b/src/renderer/src/components/native-chat/native-chat-new-rows-marker.ts index a95d3529144..8a817dc84c9 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-rows-marker.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-rows-marker.ts @@ -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(() => {