mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
fix(native-chat): keep the "New" line in place when a turn is expanded (#29230)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user