mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(native-chat): add a "New" line above unseen messages (#28333)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+151
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18354,6 +18354,8 @@
|
||||
"toggleDetails": "ターンの詳細を切り替えます"
|
||||
},
|
||||
"jumpToLatest": "最新にジャンプ",
|
||||
"newRowsAnnouncement": "離れている間に新しいメッセージが届きました",
|
||||
"newRowsMarker": "新着",
|
||||
"find": {
|
||||
"label": "チャット内を検索",
|
||||
"noResults": "結果はありません",
|
||||
|
||||
@@ -18354,6 +18354,8 @@
|
||||
"toggleDetails": "턴 세부정보 전환"
|
||||
},
|
||||
"jumpToLatest": "최신으로 이동",
|
||||
"newRowsAnnouncement": "자리를 비운 동안 새 메시지가 도착했습니다",
|
||||
"newRowsMarker": "새 메시지",
|
||||
"find": {
|
||||
"label": "채팅에서 찾기",
|
||||
"noResults": "결과 없음",
|
||||
|
||||
@@ -18319,6 +18319,8 @@
|
||||
"toggleDetails": "切换回合详情"
|
||||
},
|
||||
"jumpToLatest": "跳到最新消息",
|
||||
"newRowsAnnouncement": "你离开期间有新消息",
|
||||
"newRowsMarker": "新消息",
|
||||
"find": {
|
||||
"label": "在聊天中查找",
|
||||
"noResults": "没有结果",
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user