feat(native-chat): add a "New" line above unseen messages (#28333)

This commit is contained in:
Kelvin Amoaba
2026-10-15 00:55:06 +00:00
committed by GitHub
parent d78363ab36
commit c056cfc140
38 changed files with 1159 additions and 49 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ The chat root (`.native-chat-appearance`) redefines these steps as ratios of the
- **Secondary text on the chat canvas** (transcript rows, notices, labels between messages) uses `text-chat-foreground-faint`. `text-muted-foreground` is for text on a lifted surface inside the chat: cards, menus, popovers and the docks above the composer.
- **Row hover** in the transcript is `hover:bg-accent/20`. Buttons, menu items and card controls use `hover:bg-accent`.
- **Focus rings:** full-width transcript rows use the inset ring (`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring`) so it isn't clipped by the scroll container; standalone controls use `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. Inside the chat `--ring` is `--chat-focus-ring`, which clears 3:1 on the canvas; don't lower its opacity.
- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row.
- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`; what the reader has not seen yet uses `text-chat-unread`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row.
## Radius
@@ -119,6 +119,11 @@ describe('forward history SQL read budget', () => {
Array.from({ length: count }, (_, index) => `item-${index}`)
)
expect(batches.at(-1)?.batch.cursor).toEqual(journal.cursor())
// Only the last page says the reader is caught up.
expect(batches.map((event) => event.hasNewer === true)).toEqual([
...Array<boolean>(9).fill(true),
false
])
expect(returnedRows).toEqual([...Array<number>(9).fill(201), 200])
expect(parse).toHaveBeenCalledTimes(2_009)
})
@@ -115,6 +115,7 @@ export function deliverToSubscriber(
},
fence: subscriber.fence,
...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}),
...(page.hasNewer ? { hasNewer: true as const } : {}),
...shared
},
// On a multi-page catch-up the draft list rides only the final page, or a
+3
View File
@@ -58,6 +58,7 @@
--color-chat-foreground: var(--chat-foreground);
--color-chat-foreground-strong: var(--chat-foreground-strong);
--color-chat-foreground-faint: var(--chat-foreground-faint);
--color-chat-unread: var(--chat-unread);
--color-chat-user-surface: var(--chat-user-surface);
--color-chat-user-border: var(--chat-user-border);
--color-chat-code-foreground: var(--chat-code-foreground);
@@ -637,6 +638,8 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) {
/* Mixed toward the foreground so each clears text contrast on the canvas in either scheme. */
--chat-focus-ring: color-mix(in srgb, var(--chat-source-foreground) 50%, var(--chat-canvas));
--chat-warning: color-mix(in srgb, var(--status-warning) 70%, var(--chat-source-foreground));
/* What the reader has not seen yet: the transcript's "New" marker, in the note hue. */
--chat-unread: color-mix(in srgb, var(--callout-note) 70%, var(--chat-source-foreground));
--chat-diff-added: color-mix(
in srgb,
var(--git-decoration-added) 70%,
@@ -0,0 +1,23 @@
import { useMemo } from 'react'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatTaskList } from './NativeChatTaskList'
import { nativeChatTaskListState } from './native-chat-task-list-state'
/** The conversation's current task list, docked under the transcript. */
export function NativeChatDockedTaskList({
messages
}: {
messages: readonly NativeChatMessage[]
}): React.JSX.Element | null {
const { list } = useMemo(() => nativeChatTaskListState(messages), [messages])
if (!list || list.tasks.length === 0) {
return null
}
return (
<div className="shrink-0 px-3 pb-2 sm:px-4">
<div className="mx-auto w-full max-w-(--chat-content-max-width)">
<NativeChatTaskList list={list} presentation="composer" />
</div>
</div>
)
}
@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NativeChatMessageList } from './NativeChatMessageList'
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import {
deliverResizes,
layout,
marker,
ROW_PITCH_PX,
scrollTranscript,
session,
stubLayout,
stubResizeObserver,
TRANSCRIPT_LENGTH
} from './native-chat-windowing-test-harness'
// Pacing is not this test's subject.
vi.mock('./use-native-chat-paced-text', async (importOriginal) =>
(await import('./native-chat-unpaced-text-fixture')).unpacedTextModule(importOriginal)
)
afterEach(cleanup)
/** A transcript of rows the journal placed, as a structured chat's are. */
function chat(
length: number,
isVisible: boolean,
{
ref,
readCurrent = true
}: { ref?: React.Ref<NativeChatMessageListHandle>; readCurrent?: boolean } = {}
): React.JSX.Element {
const messages: NativeChatMessage[] = Array.from({ length }, (_, index) => ({
...marker(index),
journalPosition: { sequence: index + 1, index: 0 }
}))
return (
<NativeChatMessageList
ref={ref}
session={{ ...session(messages), readCurrent }}
isVisible={isVisible}
isWorking={false}
expandSignal={false}
/>
)
}
describe('the "New" marker in the transcript', () => {
let paneShown = true
let restore: (() => void)[] = []
beforeEach(() => {
paneShown = true
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
restore = [
stubLayout({ scrollGeometry: true, offsetChain: true, isVisible: () => paneShown }),
stubResizeObserver()
]
})
afterEach(() => {
for (const undo of restore.toReversed()) {
undo()
}
layout.measuredRowHeights = []
vi.restoreAllMocks()
})
// The window mounts only rows near the view; the marker's row is far from the end it opens at.
it('is drawn above the first row that came while the chat was hidden, however far back', () => {
const { rerender } = render(chat(TRANSCRIPT_LENGTH, true))
rerender(chat(TRANSCRIPT_LENGTH, false))
rerender(chat(TRANSCRIPT_LENGTH + 60, true))
const row = screen.getByRole('separator', { name: 'New' }).closest<HTMLElement>('[data-index]')
expect(row?.dataset.index).toBe(String(TRANSCRIPT_LENGTH))
})
it('tells a screen reader that new messages came, in a region that was already there', () => {
const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true))
const region = container.querySelector('[data-native-chat-scroll] ~ [aria-live="polite"]')
expect(region).toBeEmptyDOMElement()
rerender(chat(TRANSCRIPT_LENGTH, false))
rerender(chat(TRANSCRIPT_LENGTH + 60, true))
expect(region).toHaveTextContent('New messages arrived while you were away')
})
it('goes once the reader sends something', () => {
const ref = createRef<NativeChatMessageListHandle>()
const { rerender } = render(chat(TRANSCRIPT_LENGTH, true, { ref }))
rerender(chat(TRANSCRIPT_LENGTH, false, { ref }))
rerender(chat(TRANSCRIPT_LENGTH + 60, true, { ref }))
expect(screen.getByRole('separator', { name: 'New' })).toBeInTheDocument()
act(() => ref.current?.revealLatest())
expect(screen.queryByRole('separator', { name: 'New' })).toBeNull()
})
describe('when the reader comes back to new rows', () => {
/** The pane is hidden and shown again; the read catches up after the reveal has put the
* reader's place back. */
function returnToNewRows(rerender: (ui: React.JSX.Element) => void, count = 60): void {
paneShown = false
rerender(chat(TRANSCRIPT_LENGTH, false, { readCurrent: false }))
paneShown = true
rerender(chat(TRANSCRIPT_LENGTH, true, { readCurrent: false }))
rerender(chat(TRANSCRIPT_LENGTH + count, true))
}
const scrollTop = (container: HTMLElement): number | undefined =>
container.querySelector('[data-native-chat-scroll]')?.scrollTop
it('brings a reader who was at the end to where the new rows begin', () => {
const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true))
returnToNewRows(rerender)
expect(scrollTop(container)).toBe(TRANSCRIPT_LENGTH * ROW_PITCH_PX)
})
// A reply is drawn short and revealed over the frames after it arrives.
it('keeps the reader where the new rows begin while a reply is still being drawn', async () => {
const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true))
returnToNewRows(rerender, 1)
const drawn: number[] = []
drawn[TRANSCRIPT_LENGTH] = 2_000
layout.measuredRowHeights = drawn
act(() => void deliverResizes())
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})
expect(scrollTop(container)).toBe(TRANSCRIPT_LENGTH * ROW_PITCH_PX)
})
it('leaves a reader who was parked further up where they were', () => {
const { container, rerender } = render(chat(TRANSCRIPT_LENGTH, true))
const readingAt = 2_000
scrollTranscript(container, readingAt)
returnToNewRows(rerender)
expect(screen.getByRole('separator', { name: 'New' })).toBeInTheDocument()
expect(scrollTop(container)).toBe(readingAt)
})
})
})
@@ -3,6 +3,7 @@ import {
NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS
} from './native-chat-appearance-style'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'
import { NativeChatNewRowsAnnouncement } from './NativeChatNewRowsMarker'
import { useNativeChatRowTypography } from './use-native-chat-row-typography'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
@@ -10,9 +11,8 @@ import { translate } from '@/i18n/i18n'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection'
import { structuredQuestionTranscript } from './structured-agent-question-projection'
import { nativeChatTaskListState } from './native-chat-task-list-state'
import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
import { NativeChatTaskList } from './NativeChatTaskList'
import { NativeChatDockedTaskList } from './NativeChatDockedTaskList'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { NativeChatAwaitingInputRow } from './NativeChatAwaitingInputRow'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
@@ -38,6 +38,7 @@ import { useNativeChatSubagentSections } from './use-native-chat-subagent-sectio
import { toggleNativeChatExpandedKey } from './native-chat-expanded-keys'
import { useNativeChatTurnMembership } from './use-native-chat-turn-membership'
import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll'
import { useNativeChatNewRowsMarker } from './native-chat-new-rows-marker'
import { useNativeChatOlderHistoryAutoload } from './use-native-chat-older-history-autoload'
import { NativeChatOlderHistoryRow } from './NativeChatOlderHistoryRow'
import { useNativeChatMessageRail } from './use-native-chat-message-rail'
@@ -163,7 +164,6 @@ export function NativeChatMessageList({
[messages, subagentSections]
)
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages])
const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages])
// Each row's turn, which turn is live, and the order the rows draw in, resolved once.
const turnRows = useNativeChatTurnMembership(
messages,
@@ -227,6 +227,11 @@ export function NativeChatMessageList({
),
[allSlots, journalItems, stopping, journalSubmissions]
)
const { marker: newRowsMarker, dismiss } = useNativeChatNewRowsMarker(
slots,
waitingSlots,
isVisible && session.readCurrent === true
)
const transcriptWindow = useNativeChatTranscriptWindow({
scrollRef,
slots,
@@ -234,6 +239,7 @@ export function NativeChatMessageList({
// One pin serves both: revealing a diff and jumping from the rail are
// mutually exclusive things to be doing.
revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId),
newRowsMarkerIndex: nativeChatSlotIndexOf(slots, newRowsMarker?.key),
typography
})
const { showJump, onScroll, scrollToBottom, scrollMessageToTop, readerLeavesEnd } =
@@ -321,7 +327,7 @@ export function NativeChatMessageList({
onTakeScroll: transcriptWindow.cancelAlign,
onLeaveEnd: readerLeavesEnd
})
useNativeChatMessageListHandle(ref, jumpToLatest, readerScrollInput.revealFindMatch)
useNativeChatMessageListHandle(ref, jumpToLatest, readerScrollInput.revealFindMatch, dismiss)
useNativeChatRailJumpLanding({
railJump,
slots,
@@ -398,6 +404,7 @@ export function NativeChatMessageList({
loadedMessageIds={loadedMessageIds}
context={rowContext}
window={transcriptWindow}
newRowsMarker={newRowsMarker}
/>
{liveLine ? (
<NativeChatTurnActivityLine
@@ -420,18 +427,9 @@ export function NativeChatMessageList({
pendingId={railHistoryJump.pendingId}
/>
<NativeChatJumpToLatest visible={showJump} onJump={jumpToLatest} />
<NativeChatNewRowsAnnouncement marked={newRowsMarker !== undefined} />
</div>
{taskListState.list && taskListState.list.tasks.length > 0 ? (
<div className="shrink-0 px-3 pb-2 sm:px-4">
<div className="mx-auto w-full max-w-(--chat-content-max-width)">
<NativeChatTaskList
key={session.sessionId}
list={taskListState.list}
presentation="composer"
/>
</div>
</div>
) : null}
<NativeChatDockedTaskList key={session.sessionId} messages={messages} />
</div>
</NativeChatDisclosureContext.Provider>
)
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatNewRowsMarker } from './NativeChatNewRowsMarker'
import type { NativeChatNewRowsMarkerState as Marker } from './native-chat-new-rows-marker'
import { overrideLayoutProperty } from './native-chat-windowing-test-harness'
afterEach(cleanup)
function marker(overrides: Partial<Marker> = {}): Marker {
return {
key: 'answer',
place: 'above-row',
arriving: true,
onArrived: vi.fn(),
onDismiss: vi.fn(),
...overrides
}
}
const VIEWPORT_PX = 600
const ROW_TOP_PX = 1_000
let restoreLayout: (() => void)[] = []
/** A chat pane whose marker sits `intoRowPx` down its row. The view's bottom edge rests
* `aboveMarkerPx` short of the marker: 0 for a reader who was at the end of what there was. */
function pane(
value: Marker,
{ intoRowPx = 0, aboveMarkerPx = 0 }: { intoRowPx?: number; aboveMarkerPx?: number } = {}
) {
const isRow = (element: HTMLElement): boolean => element.hasAttribute('data-index')
const isMarker = (element: HTMLElement): boolean => element.getAttribute('role') === 'separator'
const layout = (name: string, get: (this: HTMLElement) => unknown) =>
overrideLayoutProperty(name, { get })
restoreLayout = [
layout('clientHeight', () => VIEWPORT_PX),
layout('scrollTop', () => ROW_TOP_PX + intoRowPx - VIEWPORT_PX - aboveMarkerPx),
layout('offsetTop', function () {
return isRow(this) ? ROW_TOP_PX : isMarker(this) ? intoRowPx : 0
}),
layout('offsetParent', function () {
return isRow(this) || isMarker(this) ? this.parentElement : null
})
]
const onReveal = vi.fn()
const view = render(
<div data-native-chat-root="true">
<div data-native-chat-typing-redirect-ignore="true">
<input aria-label="Find in chat" />
</div>
<div data-native-chat-scroll>
<div data-index="3">
<NativeChatNewRowsMarker marker={value} onReveal={onReveal} />
</div>
</div>
<textarea aria-label="composer" />
</div>
)
return { ...view, onReveal }
}
describe('NativeChatNewRowsMarker', () => {
afterEach(() => {
for (const restore of restoreLayout) {
restore()
}
})
it('brings its row to the top for a reader who was at the end', () => {
const value = marker()
const { container, onReveal } = pane(value)
expect(onReveal).toHaveBeenCalledWith(container.querySelector('[data-index]'))
expect(value.onArrived).toHaveBeenCalled()
})
// Under a prompt the reader expanded, the row's top would leave the marker below the view.
it('brings itself to the top when it sits too far down its row to show with it', () => {
const { onReveal } = pane(marker({ place: 'above-turn-bar' }), { intoRowPx: 500 })
expect(onReveal).toHaveBeenCalledWith(screen.getByRole('separator'))
})
// They scrolled up to read something else before they left: that place is theirs to keep.
it('leaves a reader who had parked the view above the new rows where they were', () => {
const value = marker()
const { onReveal } = pane(value, { aboveMarkerPx: 400 })
expect(onReveal).not.toHaveBeenCalled()
expect(value.onArrived).toHaveBeenCalled()
})
it('does not pull the reader back to it once it has arrived', () => {
const { onReveal } = pane(marker({ arriving: false }))
expect(onReveal).not.toHaveBeenCalled()
})
it('goes once the reader writes to this chat, not to another pane or its find field', () => {
const value = marker()
pane(value)
const typeIn = (target: Element) =>
fireEvent(target, new Event('beforeinput', { bubbles: true }))
typeIn(document.body)
typeIn(screen.getByLabelText('Find in chat'))
expect(value.onDismiss).not.toHaveBeenCalled()
typeIn(screen.getByLabelText('composer'))
expect(value.onDismiss).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,87 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { translate } from '@/i18n/i18n'
import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
import { nativeChatScrollOffsetWithin } from './use-native-chat-viewport-align'
/** Fields in the pane that take text for something other than the conversation. */
const NOT_THE_CONVERSATION = '[data-native-chat-typing-redirect-ignore="true"]'
/** Marks where what came while the reader was away begins. */
export function NativeChatNewRowsMarker({
marker: { arriving, onArrived, onDismiss },
onReveal
}: {
marker: NativeChatNewRowsMarkerState
/** Scrolls a transcript element to the top of the view, and stops following the end. */
onReveal: (element: HTMLElement) => void
}): React.JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const label = translate('components.native-chat.newRowsMarker', 'New')
useLayoutEffect(() => {
if (!arriving) {
return
}
onArrived()
const marker = ref.current
const row = marker?.closest<HTMLElement>('[data-index]')
const scroller = row?.closest<HTMLElement>('[data-native-chat-scroll]')
if (!marker || !row || !scroller) {
return
}
const top = nativeChatScrollOffsetWithin(marker, scroller)
// A view parked above where the new rows begin is on something else: it is the reader's.
// How much came is not asked: a reply is drawn short and revealed over the frames after.
if (
top === null ||
top - (scroller.scrollTop + scroller.clientHeight) > NATIVE_CHAT_BOTTOM_THRESHOLD_PX
) {
return
}
// The row's top keeps the reader's prompt in view above the marker, unless the prompt is
// tall enough to push the marker down the screen or off it.
const intoRow = top - (nativeChatScrollOffsetWithin(row, scroller) ?? top)
onReveal(intoRow < scroller.clientHeight / 2 ? row : marker)
}, [arriving, onArrived, onReveal])
useEffect(() => {
const pane = ref.current?.closest('[data-native-chat-root]')
// Writing to this chat is the reader moving on. Reading, scrolling, clicking and typing
// into a field that is not the conversation's, such as Find, are not.
const onInput = (event: Event): void => {
if (!(event.target instanceof Element && event.target.closest(NOT_THE_CONVERSATION))) {
onDismiss()
}
}
pane?.addEventListener('beforeinput', onInput)
return () => pane?.removeEventListener('beforeinput', onInput)
}, [onDismiss])
return (
<div
ref={ref}
role="separator"
aria-label={label}
className="flex items-center gap-3 text-2xs font-semibold uppercase tracking-[0.05em] text-chat-unread transition-opacity duration-300 ease-out starting:opacity-0 motion-reduce:transition-none"
>
<span>{label}</span>
<span aria-hidden className="h-px flex-1 bg-chat-unread/50" />
</div>
)
}
/** Tells a screen reader that the marker appeared. Always mounted: a live region that arrives
* with its text already in it is not announced. */
export function NativeChatNewRowsAnnouncement({ marked }: { marked: boolean }): React.JSX.Element {
return (
<div aria-live="polite" className="sr-only">
{marked
? translate(
'components.native-chat.newRowsAnnouncement',
'New messages arrived while you were away'
)
: null}
</div>
)
}
@@ -168,6 +168,7 @@ export function createStructuredSessionMocks() {
threadGoal: nullable<StructuredAgentSessionThreadGoal>(),
stopBackgroundTask: vi.fn<StopBackgroundTaskSpy>(),
hasOlder: false,
readCurrent: false,
loadingOlder: false,
olderHistoryGeneration: 0,
loadOlder: vi.fn<() => Promise<NativeChatOlderPageResult>>(),
@@ -217,6 +218,7 @@ export function createStructuredSessionMocks() {
error: null,
readRefusal: mocks.readRefusal,
hasOlder: mocks.hasOlder,
readCurrent: mocks.readCurrent,
loadingOlder: mocks.loadingOlder,
olderHistoryGeneration: mocks.olderHistoryGeneration,
loadOlder: mocks.loadOlder,
@@ -402,6 +404,7 @@ export function createStructuredSessionMocks() {
mocks.settledBackgroundTasks = []
mocks.threadGoal = null
mocks.hasOlder = false
mocks.readCurrent = false
mocks.loadingOlder = false
mocks.olderHistoryGeneration = 0
mocks.loadOlder.mockReset()
@@ -309,6 +309,27 @@ describe('NativeChatStructuredSession', () => {
expect(mocks.loadOlder).toHaveBeenCalledOnce()
})
// What the transcript marks as new rests on this: without it the list never counts as watched.
it('tells the list whether the read has caught up with the host', () => {
// A fresh element each time: React skips a rerender handed the very same one.
const pane = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-current"
sessionId="session-current"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(pane())
expect(mocks.messageListProps?.session).toMatchObject({ readCurrent: false })
mocks.readCurrent = true
rerender(pane())
expect(mocks.messageListProps?.session).toMatchObject({ readCurrent: true })
})
// Transcript image previews shipped Codex-first, and the runtime context they
// need comes from the tab rather than the agent, so it is never agent-gated.
it('hands the transcript the image runtime context', () => {
@@ -5,6 +5,7 @@ import {
} from './NativeChatTranscriptRow'
import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots'
import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window'
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
import {
NativeChatReplyRevealsContext,
useNativeChatReplyReveals
@@ -15,12 +16,14 @@ export function NativeChatTranscriptItems({
slots,
loadedMessageIds,
context,
window
window,
newRowsMarker
}: {
slots: readonly NativeChatTranscriptSlot[]
loadedMessageIds: ReadonlySet<string>
context: NativeChatTranscriptRowContext
window: NativeChatTranscriptWindow
newRowsMarker?: NativeChatNewRowsMarkerState
}): React.JSX.Element {
const rowKeys = useMemo(() => slots.map(nativeChatSlotKey), [slots])
const replyReveals = useNativeChatReplyReveals(rowKeys, loadedMessageIds)
@@ -37,7 +40,13 @@ export function NativeChatTranscriptItems({
<Fragment key={item.key}>
<div aria-hidden style={{ height: Math.max(0, item.start - previousEnd) }} />
<div data-index={item.index} ref={window.measureRow} className="flow-root w-full">
<NativeChatTranscriptRow slot={slot} context={context} />
<NativeChatTranscriptRow
slot={slot}
context={context}
newRowsMarker={
rowKeys[item.index] === newRowsMarker?.key ? newRowsMarker : undefined
}
/>
</div>
</Fragment>
)
@@ -14,6 +14,8 @@ import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs'
import { NativeChatRewindContext } from './native-chat-rewind-context'
import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility'
import { NativeChatNewRowsMarker } from './NativeChatNewRowsMarker'
import type { NativeChatNewRowsMarkerState } from './native-chat-new-rows-marker'
/** Everything a row needs that is the same for every row. Held as one memoized
* object so a row's props change only when that row's own slot does. */
@@ -42,10 +44,13 @@ export type NativeChatTranscriptRowContext = {
* wrapper carries that spacing itself — the gap BETWEEN rows is the window's. */
export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
slot,
context
context,
newRowsMarker
}: {
slot: NativeChatTranscriptSlot
context: NativeChatTranscriptRowContext
/** The "New" marker, when it draws in this row. */
newRowsMarker?: NativeChatNewRowsMarkerState
}): React.JSX.Element {
// Offered only on the conversation's own turn-opening prompts (`nativeChatRowOffersRewind`).
const rewind = useContext(NativeChatRewindContext)
@@ -81,6 +86,9 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
const deliveryNotice = context.deliveryNotices?.get(message.id)
const predecessors = context.taskListPredecessors.get(message.id)
const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined
const marker = newRowsMarker ? (
<NativeChatNewRowsMarker marker={newRowsMarker} onReveal={context.onScrollMessageToTop} />
) : null
const statusRow = status ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
@@ -95,6 +103,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
return (
// The negative margin shortens the measured row, which is what moves the next one up.
<div className={cn('flex flex-col gap-5', slot.continuesTurn && '-mb-3', sectionClassName)}>
{newRowsMarker?.place === 'above-row' ? marker : null}
{/* A turn with no user bubble carries its bar above its first row. */}
{slot.statusAbove ? statusRow : null}
{!slot.drawsMessage ? null : receipt ? (
@@ -131,6 +140,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
workRun={workRun}
/>
)}
{newRowsMarker?.place === 'above-turn-bar' ? marker : null}
{slot.statusAbove ? null : statusRow}
{turnDiff ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
@@ -95,6 +95,10 @@ describe('matching chat surfaces with opposite app schemes', () => {
expect(matched.getPropertyValue('--chat-source-foreground')).toBe(
terminalScheme === 'light' ? '#000000' : '#ffffff'
)
// The unread marker is mixed toward the chat's own foreground, not the app's.
expect(matched.getPropertyValue('--chat-unread')).toContain(
terminalScheme === 'light' ? '#0969da 70%, #000000' : '#4493f8 70%, #ffffff'
)
const codeForeground = matched.getPropertyValue('--chat-code-foreground')
expect(codeForeground).toContain(`${terminalScheme === 'light' ? '#000000' : '#ffffff'} 85%`)
expect(codeForeground).toContain(terminalScheme === 'light' ? '#ffffff' : '#000000')
@@ -44,4 +44,7 @@ export type NativeChatLiveSession = NativeChatSession & {
* outranks (and so hides) 'loading', which would let a consumer deciding from
* an empty list treat an in-flight transcript as real history. */
readPhase: ReadState['phase']
/** The transcript is the host's present, not what a stopped or disconnected read left
* behind. Absent on a chat read from a terminal, which cannot say what the reader missed. */
readCurrent?: boolean
}
@@ -0,0 +1,313 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
import {
nativeChatNewRowsMarkerPlacement,
useNativeChatNewRowsMarker
} from './native-chat-new-rows-marker'
const at = (sequence: number) => ({ sequence, index: 0 })
/** A row the journal placed at `sequence`; null for one it has not (a terminal-backed chat's). */
function row(
id: string,
sequence: number | null,
role: NativeChatMessage['role'] = 'assistant',
text = id
): NativeChatMessage {
return {
id,
role,
blocks: [{ type: 'text', text }],
timestamp: 1,
source: 'transcript',
...(sequence === null ? {} : { journalPosition: at(sequence) })
}
}
/** The rows as one turn: still `working`, or settled after `worked` seconds, or neither. */
type Turn = { working?: boolean; worked?: number }
function slotsOf(messages: NativeChatMessage[], { working = false, worked }: Turn = {}) {
const turnKey = working || worked !== undefined ? 'prompt' : undefined
return buildNativeChatTranscriptSlots({
messages,
turnKeys: messages.map(() => turnKey),
liveTurnKey: working ? turnKey : undefined,
receipts: new Map(),
turnStatuses: {
active: null,
completedByTurn:
worked === undefined
? {}
: { prompt: { startedAt: null, thinking: false, workedSeconds: worked } }
},
turnDiffs: new Map(),
expandedTurnKeys: new Set(),
isWorking: working,
lifecycleWorking: working
})
}
const SEEN = [row('prompt', 1, 'user'), row('reply', 2)]
const LATER = [...SEEN, row('answer', 3), row('more', 4)]
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({
key: 'answer',
place: 'above-row'
})
})
// 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'
})
})
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()
})
})
describe('useNativeChatNewRowsMarker', () => {
let hasFocus: ReturnType<typeof vi.spyOn>
beforeEach(() => {
hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true)
})
afterEach(() => hasFocus.mockRestore())
type ChatProps = {
messages: NativeChatMessage[]
showing: boolean
waiting?: NativeChatMessage[]
}
function chat(messages: NativeChatMessage[], turn?: Turn) {
const initialProps: ChatProps = { messages, showing: true }
return renderHook(
(props: ChatProps) =>
useNativeChatNewRowsMarker(
slotsOf(props.messages, turn),
slotsOf(props.waiting ?? []),
props.showing
),
{ initialProps }
)
}
/** The reader goes to another tab, the chat becomes `onReturn`, and they come back. */
function leaveAndReturn(left: NativeChatMessage[], onReturn: NativeChatMessage[], turn?: Turn) {
const view = chat(left, turn)
view.rerender({ messages: left, showing: false })
view.rerender({ messages: onReturn, showing: true })
return view
}
it('marks nothing that arrives while the reader is looking', () => {
const view = chat(SEEN)
view.rerender({ messages: LATER, showing: true })
expect(view.result.current.marker).toBeUndefined()
})
it('marks what a hidden chat is handed when the reader comes back to it', () => {
expect(leaveAndReturn(SEEN, LATER).result.current.marker?.key).toBe('answer')
})
it("is never above the reader's own prompt", () => {
const sent = [...SEEN, row('next-prompt', 3, 'user'), row('answer', 4)]
expect(leaveAndReturn(SEEN, sent).result.current.marker?.key).toBe('answer')
})
it('marks nothing the reader watches arrive after missing nothing', () => {
const view = leaveAndReturn(SEEN, SEEN)
view.rerender({ messages: LATER, showing: true })
expect(view.result.current.marker).toBeUndefined()
})
it('marks what came while the window was in the background', () => {
const view = chat(SEEN)
hasFocus.mockReturnValue(false)
act(() => {
window.dispatchEvent(new Event('blur'))
})
view.rerender({ messages: LATER, showing: true })
hasFocus.mockReturnValue(true)
act(() => {
window.dispatchEvent(new Event('focus'))
})
expect(view.result.current.marker?.key).toBe('answer')
})
it('goes once the reader answers', () => {
const view = leaveAndReturn(SEEN, LATER)
act(() => view.result.current.dismiss())
expect(view.result.current.marker).toBeUndefined()
})
it('does not appear when the reader answered before the chat had caught up', () => {
const view = chat(SEEN)
view.rerender({ messages: SEEN, showing: false })
act(() => view.result.current.dismiss())
view.rerender({ messages: LATER, showing: true })
expect(view.result.current.marker).toBeUndefined()
})
// The reader left before the journal had placed the prompt they had just sent.
it('marks the first reply to a chat that held no row the journal had placed', () => {
const sending = [row('prompt', null, 'user')]
const view = leaveAndReturn(sending, [row('prompt', 1, 'user'), row('answer', 2)])
expect(view.result.current.marker?.key).toBe('answer')
})
it('is arriving on the return, until the row that draws it has brought it into view', () => {
const view = leaveAndReturn(SEEN, LATER)
expect(view.result.current.marker?.arriving).toBe(true)
act(() => view.result.current.marker?.onArrived())
expect(view.result.current.marker).toMatchObject({ key: 'answer', arriving: false })
})
// The reader may be midway through what it marks: a glance at another tab must not move it.
it('stays where it is through another look away, until the reader answers', () => {
const view = leaveAndReturn(SEEN, LATER)
act(() => view.result.current.marker?.onArrived())
view.rerender({ messages: LATER, showing: false })
expect(view.result.current.marker?.key).toBe('answer')
view.rerender({ messages: [...LATER, row('newer', 5)], showing: true })
expect(view.result.current.marker).toMatchObject({ key: 'answer', arriving: false })
})
it('marks the next time away afresh once the reader has answered', () => {
const view = leaveAndReturn(SEEN, LATER)
act(() => view.result.current.dismiss())
view.rerender({ messages: LATER, showing: false })
view.rerender({ messages: [...LATER, row('newer', 5)], showing: true })
expect(view.result.current.marker).toMatchObject({ key: 'newer', arriving: true })
})
it('marks the next time away afresh when the row it marked has gone', () => {
const view = leaveAndReturn(SEEN, LATER)
view.rerender({ messages: SEEN, showing: true })
expect(view.result.current.marker).toBeUndefined()
view.rerender({ messages: SEEN, showing: false })
view.rerender({ messages: [...SEEN, row('newer', 5)], showing: true })
expect(view.result.current.marker?.key).toBe('newer')
})
// Another agent's message waits behind the running turn, drawn outside the transcript's rows.
it('marks a message that was held behind the running turn once it joins the transcript', () => {
const view = chat(SEEN)
view.rerender({ messages: SEEN, showing: false })
view.rerender({ messages: SEEN, showing: true, waiting: [row('held', 3)] })
expect(view.result.current.marker).toBeUndefined()
// The turn writes on in front of the reader, then the handover places the message after that.
view.rerender({ messages: [...SEEN, row('live', 4)], showing: true, waiting: [row('held', 3)] })
expect(view.result.current.marker).toBeUndefined()
view.rerender({ messages: [...SEEN, row('live', 4), row('held', 5)], showing: true })
expect(view.result.current.marker?.key).toBe('held')
})
// Queued behind an ordinary turn, it draws after everything the agent goes on to write.
it('stays above a queued message while the turn writes on ahead of it', () => {
const queued: NativeChatMessage = { ...row('held', 3), queued: true }
const view = leaveAndReturn(SEEN, [...SEEN, queued])
expect(view.result.current.marker?.key).toBe('held')
view.rerender({ messages: [...SEEN, row('live', 4), queued], showing: true })
expect(view.result.current.marker?.key).toBe('held')
})
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.')]
const marked = (onReturn: NativeChatMessage[]) =>
leaveAndReturn(begun, onReturn, { working: true }).result.current.marker?.key
it('is marked new when it grew while they were away', () => {
expect(marked(grown)).toBe('reply')
})
it('is not marked when it is as they left it', () => {
expect(marked(begun)).toBeUndefined()
})
it('gives way to the next row when it is as they left it', () => {
expect(marked([...begun, row('more', 3)])).toBe('more')
})
it('is marked new when it grew with no row placed before it', () => {
const alone = [row('reply', 2, 'assistant', 'The plan')]
const grownAlone = [row('reply', 2, 'assistant', 'The plan has parts.')]
const view = leaveAndReturn(alone, grownAlone, { working: true })
expect(view.result.current.marker?.key).toBe('reply')
})
describe('with a follow-up delivered into the turn after it', () => {
const followUp = row('follow-up', 3, 'user')
const marked = (onReturn: NativeChatMessage[]) =>
leaveAndReturn([...begun, followUp], onReturn, { working: true }).result.current.marker?.key
it('is still marked new when it grew', () => {
expect(marked([...grown, followUp])).toBe('reply')
})
it('gives way to what answers the follow-up when it is as they left it', () => {
expect(marked([...begun, followUp, row('answer', 4)])).toBe('answer')
})
it('marks nothing when nothing changed', () => {
expect(marked([...begun, followUp])).toBeUndefined()
})
})
describe('with a message held behind the turn, placed after it', () => {
const markedWith = (held: NativeChatMessage, onReturn: NativeChatMessage[]) => {
const view = chat(begun, { working: true })
view.rerender({ messages: begun, showing: true, waiting: [held] })
view.rerender({ messages: begun, showing: false, waiting: [held] })
view.rerender({ messages: onReturn, showing: true, waiting: [held] })
return view.result.current.marker?.key
}
it('is still marked new when it grew', () => {
expect(markedWith(row('held', 3, 'user'), grown)).toBe('reply')
})
it('marks nothing when neither it nor the held message changed', () => {
const fromAgent: NativeChatMessage = {
...row('held', 3, 'user'),
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null },
name: 'Claude'
}
],
orchestration: null
}
}
// Seen while it waited, it is not news once the turn ends and it joins the transcript.
expect(markedWith(fromAgent, [...begun, fromAgent])).toBeUndefined()
})
})
})
it('marks nothing in a chat read from a terminal, whose rows the journal never placed', () => {
const view = leaveAndReturn([row('a', null)], [row('a', null), row('b', null)])
expect(view.result.current.marker).toBeUndefined()
})
})
@@ -0,0 +1,188 @@
// Where the conversation stood when the reader last looked away from it: the "New" marker draws
// above the first row that came, or kept growing, while they were away.
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'
/** 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. */
function rowPosition(slot: NativeChatTranscriptSlot): AgentJournalPosition | undefined {
return slot.kind === 'message' && slot.depth === 0 && slot.drawsMessage
? slot.message.journalPosition
: undefined
}
/** Before every row the journal places: what the reader has read to when no row precedes. */
const BEFORE_EVERY_ROW: AgentJournalPosition = { sequence: -1, index: 0 }
/** Where a reader who looks away now has read to, and the reply the agent is still writing. */
type NativeChatLeftAt = {
/** The newest row there was, as a position: that row may have folded away by the return. */
position: AgentJournalPosition
/** The reply still being written. Grown by the return, it counts as new, and the marker
* draws from the row `before` it. */
writing: { key: string; size: number; before: AgentJournalPosition } | null
}
function rowSize(slot: NativeChatTranscriptSlot): number {
return slot.kind === 'message'
? JSON.stringify((slot.workRun ?? [slot.message]).map((message) => message.blocks)).length
: 0
}
/** The newest row's position, among those placed before `limit` when one is given. */
function newestRowPosition(
slots: readonly NativeChatTranscriptSlot[],
limit?: AgentJournalPosition
): AgentJournalPosition {
let newest = BEFORE_EVERY_ROW
for (const slot of slots) {
const at = rowPosition(slot)
if (
at &&
(!limit || compareAgentJournalPositions(at, limit) < 0) &&
compareAgentJournalPositions(at, newest) > 0
) {
newest = at
}
}
return newest
}
function nativeChatLeftAt(
slots: readonly NativeChatTranscriptSlot[],
waitingSlots: readonly NativeChatTranscriptSlot[]
): NativeChatLeftAt {
const all = [...slots, ...waitingSlots]
// The working turn's latest row of the agent's own, wherever the reader's prompts sit: one
// delivered into the turn or held behind it comes after the reply and does not stop it growing.
const writer = slots.findLast(
(slot) =>
slot.kind === 'message' &&
slot.activeTurnIsWorking &&
slot.message.role !== 'user' &&
rowPosition(slot) !== undefined
)
const at = writer && rowPosition(writer)
const writing = at
? { key: nativeChatSlotKey(writer), size: rowSize(writer), before: newestRowPosition(all, at) }
: null
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(
slots: readonly NativeChatTranscriptSlot[],
{ position, writing }: NativeChatLeftAt
): string | 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
}
export type NativeChatNewRowsMarkerPlacement = {
/** Key of the row the marker draws in. */
key: string
place: 'above-row' | 'above-turn-bar'
}
/** What the row that draws the marker is handed. */
export type NativeChatNewRowsMarkerState = NativeChatNewRowsMarkerPlacement & {
/** The reader has just come back: bring the marker into view, once. */
arriving: boolean
onArrived: () => void
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. */
export function nativeChatNewRowsMarkerPlacement(
slots: readonly NativeChatTranscriptSlot[],
firstNew: string | null
): NativeChatNewRowsMarkerPlacement | undefined {
if (firstNew === null) {
return undefined
}
const index = nativeChatSlotIndexOf(slots, firstNew)
const first = slots[index]
if (!first) {
return undefined
}
const before = slots[index - 1]
return before?.kind === 'message' && before.status && !before.statusAbove && !before.turnDiff
? { key: nativeChatSlotKey(before), place: 'above-turn-bar' }
: { key: nativeChatSlotKey(first), place: 'above-row' }
}
export function useNativeChatNewRowsMarker(
slots: readonly NativeChatTranscriptSlot[],
/** Rows held behind the running turn: seen and missed like any other, drawn outside `slots`. */
waitingSlots: readonly NativeChatTranscriptSlot[],
/** The pane is the one showing, and shows what the host holds now. */
isShowingLatest: boolean
): {
marker: NativeChatNewRowsMarkerState | undefined
/** The reader answered the conversation, so nothing in it is news to them. */
dismiss: () => void
} {
const watching = useOrcaWindowForegroundFocused(isShowingLatest)
const [seen, setSeen] = useState<{
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
arriving: boolean
}>({ watching, left: null, firstNew: 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 })
} 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
setSeen({ ...seen, watching, left: marked ? null : nativeChatLeftAt(slots, waitingSlots) })
}
}
const { firstNew } = seen
const placement = useMemo(
() => nativeChatNewRowsMarkerPlacement(slots, firstNew),
[slots, firstNew]
)
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 }
)
}, [])
const onArrived = useCallback(() => {
setSeen((held) => (held.arriving ? { ...held, arriving: false } : held))
}, [])
const { key, place } = placement ?? {}
const marker = useMemo(
() =>
key && place
? { key, place, arriving: seen.arriving, onArrived, onDismiss: dismiss }
: undefined,
[key, place, seen.arriving, onArrived, dismiss]
)
return { marker, dismiss }
}
@@ -15,6 +15,12 @@ describe('pinned transcript rows', () => {
.toEqual([3, 39])
})
// A reveal can stand for as long as its diff stays open; the marker's row must not wait on it.
it("pins the new-rows marker's row alongside a reveal, not in its place", () => {
const pinned = nativeChatPinnedRowIndexes({ count: 40, revealIndex: 3, newRowsMarkerIndex: 20 })
expect([...pinned].sort((a, b) => a - b)).toEqual([3, 20, 39])
})
it('ignores a reveal target that is no longer in the transcript', () => {
expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: -1 })]).toEqual([4])
expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: 99 })]).toEqual([4])
@@ -14,6 +14,9 @@ export type NativeChatPinnedRowsInput = {
/** Row holding a diff the transcript was asked to reveal; it has to be mounted
* for its card to expand and report where to scroll. */
revealIndex?: number | null
/** Row drawing the "New" marker: mounted wherever the window sits, so it can bring itself
* into view when the reader returns and hear them start to answer. */
newRowsMarkerIndex?: number | null
}
/** Indexes that stay mounted outside the window.
@@ -24,15 +27,18 @@ export type NativeChatPinnedRowsInput = {
* a turn streams. */
export function nativeChatPinnedRowIndexes({
count,
revealIndex
revealIndex,
newRowsMarkerIndex
}: NativeChatPinnedRowsInput): ReadonlySet<number> {
const pinned = new Set<number>()
if (count <= 0) {
return pinned
}
pinned.add(count - 1)
if (revealIndex != null && revealIndex >= 0 && revealIndex < count) {
pinned.add(revealIndex)
for (const index of [revealIndex, newRowsMarkerIndex]) {
if (index != null && index >= 0 && index < count) {
pinned.add(index)
}
}
return pinned
}
@@ -28,6 +28,9 @@ export type StructuredAgentSessionReadSnapshot = {
/** Bumped whenever older-page reads are invalidated (open, snapshot, reset, dispose). */
olderHistoryGeneration: number
providerSession?: AgentProviderSessionMetadata
/** Whether `state` is the host's present: false from when reading stops or contact is lost
* until the read has caught up again. */
current: boolean
}
export type StructuredAgentSessionReadOwner = {
@@ -63,7 +66,8 @@ function createReadOwner(
let snapshot: StructuredAgentSessionReadSnapshot = {
state: EMPTY_STRUCTURED_AGENT_SESSION,
loadingOlder: false,
olderHistoryGeneration: 0
olderHistoryGeneration: 0,
current: false
}
let stopActiveRun: (() => void) | null = null
const retiredHistoryRead = (): boolean => true
@@ -85,8 +89,19 @@ function createReadOwner(
}
const apply = (action: StructuredAgentSessionAction): void => {
const state = reduceStructuredAgentSession(snapshot.state, action, Date.now())
if (state !== snapshot.state) {
setSnapshot({ ...snapshot, state })
// Published even when the frame changed nothing: a caught-up resume is an empty batch.
const current =
snapshot.current ||
(action.type === 'event' &&
action.event.type !== 'end' &&
!(action.event.type === 'batch' && action.event.hasNewer))
if (state !== snapshot.state || current !== snapshot.current) {
setSnapshot({ ...snapshot, state, current })
}
}
const markStale = (): void => {
if (snapshot.current) {
setSnapshot({ ...snapshot, current: false })
}
}
const setProviderSession = (providerSession: AgentProviderSessionMetadata | undefined): void => {
@@ -201,6 +216,7 @@ function createReadOwner(
apply({ type: 'error', message, refusal })
},
getCursor: () => snapshot.state.cursor,
onContactLost: markStale,
onHistoryReadInvalidated: invalidateOlderPages,
hydrate: snapshot.state.epoch === null ? hydrate : undefined,
sessionId,
@@ -211,6 +227,7 @@ function createReadOwner(
captureActiveHistoryReadGuard = () => retiredHistoryRead
transport.dispose()
stopActiveRun = null
markStale()
}
}
@@ -81,6 +81,7 @@ describe('structured agent-session read transport generations', () => {
applyEvent,
applyError,
getCursor: () => null,
onContactLost: vi.fn(),
onHistoryReadInvalidated: () => undefined,
hydrate: async () => undefined,
sessionId: 'session-a',
@@ -98,6 +99,7 @@ describe('structured agent-session read transport generations', () => {
},
applyError: vi.fn(),
getCursor: () => state.cursor,
onContactLost: vi.fn(),
onHistoryReadInvalidated: () => undefined,
sessionId: 'session-a',
target
@@ -267,6 +269,7 @@ describe('structured agent-session read transport unattached refusals', () => {
applyEvent,
applyError,
getCursor: () => null,
onContactLost: vi.fn(),
onHistoryReadInvalidated: () => undefined,
hydrate,
sessionId: 'session-a',
@@ -616,6 +619,7 @@ describe('structured agent-session read transport unattached refusals', () => {
applyEvent: vi.fn(),
applyError: vi.fn(),
getCursor: () => null,
onContactLost: vi.fn(),
onHistoryReadInvalidated: () => undefined,
hydrate: async () => undefined,
sessionId: 'session-a',
@@ -82,6 +82,8 @@ export function startStructuredAgentSessionReadTransport(args: {
/** `message` is the failure's own text, for logs; `refusal` is what a surface words. */
applyError: (message: string, refusal?: AgentSessionRefusalReference) => void
getCursor: () => AgentJournalCursor | null
/** The live read is gone until a reconnect delivers: what the pane holds is stale from here. */
onContactLost: () => void
onHistoryReadInvalidated: () => void
hydrate?: (shouldStop: () => boolean) => Promise<void>
sessionId: string
@@ -118,6 +120,13 @@ export function startStructuredAgentSessionReadTransport(args: {
reconnectScheduler.retryNow()
)
: () => {}
const loseContact = (): void => {
connected = false
// What already arrived is applied first: landing later, it would read as contact regained.
coalescer.flush()
args.onContactLost()
reconnectScheduler.schedule()
}
const isCurrentOpenGeneration = (candidate: number): boolean =>
!stopped && candidate === openGeneration
const clearUnattachedReadGrace = (): void => {
@@ -181,8 +190,7 @@ export function startStructuredAgentSessionReadTransport(args: {
return
}
} else if (event.type === 'end') {
connected = false
reconnectScheduler.schedule()
loseContact()
}
const opensSubscription = event.type !== 'end' && openingFrameGeneration === eventOpenGeneration
if (opensSubscription) {
@@ -234,17 +242,15 @@ export function startStructuredAgentSessionReadTransport(args: {
return
}
closedDuringOpen = true
connected = false
reportReadFailure(error)
reconnectScheduler.schedule()
loseContact()
},
() => {
if (!isCurrentOpenGeneration(currentOpenGeneration)) {
return
}
closedDuringOpen = true
connected = false
reconnectScheduler.schedule()
loseContact()
}
)
if (!isCurrentOpenGeneration(currentOpenGeneration) || closedDuringOpen) {
@@ -260,9 +266,8 @@ export function startStructuredAgentSessionReadTransport(args: {
if (!isCurrentOpenGeneration(currentOpenGeneration)) {
return
}
connected = false
reportReadFailure(error)
reconnectScheduler.schedule()
loseContact()
} finally {
if (currentOpenGeneration === openGeneration) {
opening = false
@@ -76,14 +76,23 @@ export function useNativeChatRevealLatest(): {
function ignoreFindMatch(): void {}
/** The transcript's side: what a pane's reveal does to this list. */
/** The transcript's side: what a pane's reveal does to this list. `onSent` hears of it too:
* unlike the reader's own jump to the latest, a reveal is them answering the conversation. */
export function useNativeChatMessageListHandle(
ref: React.Ref<NativeChatMessageListHandle> | undefined,
revealLatest: () => void,
revealFindMatch: NativeChatMessageListHandle['revealFindMatch'] = ignoreFindMatch
jumpToLatest: () => void,
revealFindMatch: NativeChatMessageListHandle['revealFindMatch'] = ignoreFindMatch,
onSent?: () => void
): void {
useImperativeHandle(ref, () => ({ revealLatest, revealFindMatch }), [
revealLatest,
revealFindMatch
])
useImperativeHandle(
ref,
() => ({
revealLatest: () => {
onSent?.()
jumpToLatest()
},
revealFindMatch
}),
[jumpToLatest, revealFindMatch, onSent]
)
}
@@ -64,6 +64,7 @@ export function useNativeChatTranscriptWindow({
slots,
isVisible,
revealIndex,
newRowsMarkerIndex,
typography = DEFAULT_ROW_TYPOGRAPHY
}: {
scrollRef: React.RefObject<HTMLDivElement | null>
@@ -71,6 +72,8 @@ export function useNativeChatTranscriptWindow({
isVisible: boolean
/** Slot the transcript was asked to reveal, or -1. */
revealIndex: number
/** Slot drawing the "New" marker, or -1. */
newRowsMarkerIndex?: number
/** Chat text scale; gap scales with the text size. */
typography?: NativeChatRowTypography
}): NativeChatTranscriptWindow {
@@ -81,8 +84,8 @@ export function useNativeChatTranscriptWindow({
const previousMeasurementKeysRef = useRef<ReadonlySet<string> | null>(null)
const retiredMeasurementCountRef = useRef(0)
const pinned = useMemo(
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }),
[slots.length, revealIndex]
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex, newRowsMarkerIndex }),
[slots.length, revealIndex, newRowsMarkerIndex]
)
// A content-only tail revision must not rebuild measured offsets: doing so
// breaks the end anchor while the row grows. Structural changes replace it.
@@ -8,7 +8,8 @@ import type {
} from '../../../../shared/agent-session-journal-types'
import {
AGENT_SESSION_HISTORY_MAX_LIMIT,
type AgentSessionHistoryPage
type AgentSessionHistoryPage,
type AgentSessionSubscribeEvent
} from '../../../../shared/agent-session-wire'
const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() }))
@@ -340,6 +341,72 @@ describe('useStructuredAgentSessionRead history window', () => {
})
})
describe('useStructuredAgentSessionRead currency', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
resetStructuredAgentSessionReadOwnersForTests()
})
it('is current only once a run has been caught up with the host', async () => {
const emits: ((event: AgentSessionSubscribeEvent) => void)[] = []
mocks.call.mockResolvedValue({
ok: true,
page: page('tail', [message('m-1', 1, 'user')], false)
})
const closes: (() => void)[] = []
mocks.subscribe.mockImplementation((_target, _params, onEvent, _onError, onClose) => {
emits.push(onEvent)
closes.push(onClose)
return Promise.resolve({ unsubscribe: vi.fn() })
})
const frame = (sequence: number, hasNewer: boolean): AgentSessionSubscribeEvent => ({
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence },
items: [],
removedItemIds: [],
submissions: []
},
...(hasNewer ? { hasNewer: true as const } : {})
})
const view = renderHook(
({ isVisible }: { isVisible: boolean }) =>
useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET, isVisible }),
{ initialProps: { isVisible: true } }
)
await waitFor(() => expect(emits).toHaveLength(1))
// History alone is not the host's present.
expect(view.result.current.readCurrent).toBe(false)
// A resume with nothing missed is an empty frame that changes no state.
act(() => emits[0]!(frame(view.result.current.state.cursor!.sequence, false)))
expect(view.result.current.readCurrent).toBe(true)
view.rerender({ isVisible: false })
expect(view.result.current.readCurrent).toBe(false)
view.rerender({ isVisible: true })
await waitFor(() => expect(emits).toHaveLength(2))
act(() => emits[1]!(frame(700, true)))
expect(view.result.current.readCurrent).toBe(false)
act(() => emits[1]!(frame(900, false)))
await waitFor(() => expect(view.result.current.readCurrent).toBe(true))
// The stream closing under a pane that is showing: what it holds is stale until the reconnect,
// and an update still waiting to be applied when it closed must not say otherwise.
act(() => {
emits[1]!(frame(950, false))
closes[1]!()
})
expect(view.result.current.readCurrent).toBe(false)
await act(() => new Promise((resolve) => setTimeout(resolve, 100)))
expect(view.result.current.readCurrent).toBe(false)
})
})
// A workspace delete closes its structured chats while the pane is still mounted, so every read
// against that session refuses `agent_session_ownership_unknown` until the tab retires. A page that
// lost that race must not leave the pane holding an error the live transport is about to clear.
@@ -58,6 +58,7 @@ export function useStructuredAgentSessionRead(args: {
return {
state: snapshot.state,
readCurrent: snapshot.current,
loadingOlder: snapshot.loadingOlder,
olderHistoryGeneration: snapshot.olderHistoryGeneration,
loadOlder: owner.loadOlder,
@@ -84,6 +84,7 @@ export function useStructuredAgentSession(args: {
const {
state,
stateRef,
readCurrent,
loadingOlder,
olderHistoryGeneration,
loadOlder,
@@ -239,6 +240,8 @@ export function useStructuredAgentSession(args: {
})
return {
epoch: state.epoch,
/** The transcript has caught up with the host since this pane last started reading. */
readCurrent: transportEnabled && readCurrent,
rewind,
conversationCommands,
...structuredConversationCommandRunner({
@@ -13,6 +13,7 @@ type StructuredChatController = Pick<
| 'loadingOlder'
| 'olderHistoryGeneration'
| 'loadOlder'
| 'readCurrent'
>
/** The structured chat as the shared transcript reads it: its rows, and a status that stays
@@ -50,7 +51,8 @@ export function useStructuredChatLiveSession(
? 'loading'
: controller.status === 'error'
? 'error'
: 'ready'
: 'ready',
readCurrent: controller.readCurrent
}),
[controller, historyPhase, agent, sessionId, hostReachable]
)
@@ -0,0 +1,25 @@
import { useSyncExternalStore } from 'react'
import { isOrcaWindowForegroundFocused } from '@/components/terminal-pane/terminal-notification-pane-visibility'
function subscribe(onChange: () => void): () => void {
window.addEventListener('focus', onChange)
window.addEventListener('blur', onChange)
document.addEventListener('visibilitychange', onChange)
return () => {
window.removeEventListener('focus', onChange)
window.removeEventListener('blur', onChange)
document.removeEventListener('visibilitychange', onChange)
}
}
const subscribeToNothing = (): (() => void) => () => {}
const notFocused = (): boolean => false
/** Whether the Orca window is the one in front and focused. `enabled` off reads false and
* listens to nothing, so a pane that is not showing never re-renders on a window switch. */
export function useOrcaWindowForegroundFocused(enabled: boolean): boolean {
return useSyncExternalStore(
enabled ? subscribe : subscribeToNothing,
enabled ? isOrcaWindowForegroundFocused : notFocused
)
}
+2
View File
@@ -18637,6 +18637,8 @@
"groupTasks": "Tasks"
},
"jumpToLatest": "Jump to latest",
"newRowsAnnouncement": "New messages arrived while you were away",
"newRowsMarker": "New",
"find": {
"label": "Find in chat",
"noResults": "No results",
+2
View File
@@ -18319,6 +18319,8 @@
"toggleDetails": "Alternar detalles del turno"
},
"jumpToLatest": "Saltar a lo último",
"newRowsAnnouncement": "Llegaron mensajes nuevos mientras no estabas",
"newRowsMarker": "Nuevo",
"find": {
"label": "Buscar en el chat",
"noResults": "Sin resultados",
+2
View File
@@ -18423,6 +18423,8 @@
"toggleDetails": "Basculer les détails du tour"
},
"jumpToLatest": "Aller au dernier message",
"newRowsAnnouncement": "De nouveaux messages sont arrivés en votre absence",
"newRowsMarker": "Nouveau",
"find": {
"label": "Rechercher dans le chat",
"noResults": "Aucun résultat",
+2
View File
@@ -18354,6 +18354,8 @@
"toggleDetails": "ターンの詳細を切り替えます"
},
"jumpToLatest": "最新にジャンプ",
"newRowsAnnouncement": "離れている間に新しいメッセージが届きました",
"newRowsMarker": "新着",
"find": {
"label": "チャット内を検索",
"noResults": "結果はありません",
+2
View File
@@ -18354,6 +18354,8 @@
"toggleDetails": "턴 세부정보 전환"
},
"jumpToLatest": "최신으로 이동",
"newRowsAnnouncement": "자리를 비운 동안 새 메시지가 도착했습니다",
"newRowsMarker": "새 메시지",
"find": {
"label": "채팅에서 찾기",
"noResults": "결과 없음",
+2
View File
@@ -18319,6 +18319,8 @@
"toggleDetails": "切换回合详情"
},
"jumpToLatest": "跳到最新消息",
"newRowsAnnouncement": "你离开期间有新消息",
"newRowsMarker": "新消息",
"find": {
"label": "在聊天中查找",
"noResults": "没有结果",
+3 -4
View File
@@ -207,6 +207,8 @@ export type AgentSessionSubscribeEvent =
/** Rides every batch that carries rows, removals or submissions, so absent there means an
* older host; absent on one that carries none, which changes no turn. */
latestTurn?: AgentSessionLatestTurn | null
/** On a catch-up page with more behind it; absent on the last, live, and from older hosts. */
hasNewer?: true
} & AgentSessionFrameFields)
| ({
type: 'reset'
@@ -350,10 +352,7 @@ export type AgentSessionQueuedSendReceipt = {
}
export type AgentSessionSendResult =
| {
clientMessageId: string
submission: AgentJournalSubmission
}
| { clientMessageId: string; submission: AgentJournalSubmission }
| { clientMessageId: string; queued: AgentSessionQueuedSendReceipt }
/** The submission arm's payload; undefined for a queued answer. For callers that
@@ -162,6 +162,27 @@ describe('structured agent session event coalescer', () => {
null
])
})
it('ends a catch-up only when the last page it merged did', () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
const page = (sequence: number, hasNewer: boolean) => ({
...batch(sequence),
...(hasNewer ? { hasNewer: true as const } : {})
})
coalescer.push(page(1, true))
coalescer.push(page(2, true))
coalescer.flush()
coalescer.push(page(3, true))
coalescer.push(page(4, false))
coalescer.flush()
expect(events.map((event) => event.type === 'batch' && event.hasNewer === true)).toEqual([
true,
false
])
})
it('drops it when a later frame carries rows without it, as applying both would', () => {
const events: AgentSessionSubscribeEvent[] = []
const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event))
@@ -55,7 +55,9 @@ function mergeBatch(
...(right.activity !== undefined || left.activity !== undefined
? { activity: right.activity !== undefined ? right.activity : (left.activity ?? null) }
: {}),
...(latestTurn !== undefined ? { latestTurn } : {})
...(latestTurn !== undefined ? { latestTurn } : {}),
// The later page's: a merged frame ends the catch-up only if its last page did.
...(right.hasNewer ? { hasNewer: true as const } : {})
}
}