feat(native-chat): preview each rail tick with its reply (#26056)

Hovering the rail opened the whole message list. Also fixes rail jumps
that were cancelled, undone or landed off when rows resized mid-scroll.
This commit is contained in:
Kelvin Amoaba
2026-10-07 16:55:29 -07:00
committed by GitHub
parent e08ba9f36d
commit e9b3efa7d7
28 changed files with 1302 additions and 851 deletions
@@ -77,6 +77,28 @@ describe('conversation outline read', () => {
expect(replyBytes(trimmed.entries)).toBeLessThanOrEqual(budget)
})
it('carries the reply to each prompt, and drops replies before it shortens a preview', () => {
const items = Array.from({ length: 10 }, (_, index): AgentJournalRenderItem[] => [
prompt(index * 2 + 1, 'x'.repeat(100)),
{
itemId: `reply-${index}`,
revision: 1,
sequence: index * 2 + 2,
observedAt: index * 2 + 2,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Answer' }] }
}
]).flat()
const full = readAgentSessionConversationOutline(snapshot(items), Number.MAX_SAFE_INTEGER)
expect(full.entries.map((entry) => entry.reply)).toEqual(Array(10).fill('Answer'))
// One byte short: the budget counts a separator per entry, the array two brackets.
const tight = readAgentSessionConversationOutline(snapshot(items), replyBytes(full.entries) - 2)
expect(tight.entries.every((entry) => entry.reply === undefined)).toBe(true)
expect(tight.entries.map((entry) => entry.preview)).toEqual(
full.entries.map((entry) => entry.preview)
)
})
it('stays inside the history page budget for a journal far past it', () => {
// ~10k prompts of 1 KB each: an order of magnitude past the budget at full preview.
const items = Array.from({ length: 10_000 }, (_, index) =>
@@ -2,8 +2,9 @@
// request rather than stored, so it can never disagree with the journal it lists.
//
// One reply must fit the same budget a history page does. Past it the reply
// degrades in order of what the rail can best spare: shorter previews, then no
// previews (a tick still marks the message), and only then the oldest entries.
// degrades in order of what the rail can best spare: the agent's replies, then
// shorter previews, then no previews (a tick still marks the message), and only
// then the oldest entries.
import {
AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS,
@@ -32,6 +33,12 @@ function withPreviewCap(
})
}
function withoutReplies(
entries: readonly AgentSessionConversationOutlineEntry[]
): AgentSessionConversationOutlineEntry[] {
return entries.map(({ reply: _reply, ...entry }) => entry)
}
function totalBytes(entries: readonly AgentSessionConversationOutlineEntry[]): number {
return entries.reduce((total, entry) => total + entryBytes(entry), 0)
}
@@ -42,6 +49,9 @@ export function readAgentSessionConversationOutline(
): AgentSessionConversationOutline {
const projected = projectAgentSessionConversationOutline(snapshot.items, snapshot.submissions)
let entries = withPreviewCap(projected, AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS)
if (totalBytes(entries) > budgetBytes) {
entries = withoutReplies(entries)
}
for (const maxChars of [DEGRADED_PREVIEW_MAX_CHARS, 0]) {
if (totalBytes(entries) <= budgetBytes) {
break
@@ -109,7 +109,6 @@ describe('revealing a diff from a turn rollup', () => {
scrollTranscript(container, 6000)
expect(screen.getByText('Edited')).toBeInTheDocument()
scrollTo.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
fireEvent.click(screen.getByRole('button', { name: 'Second prompt' }))
expect(scrollTo.mock.calls).toEqual([
[{ top: 6000, behavior: 'auto' }],
@@ -164,12 +163,7 @@ describe('jumping to a message from the rail', () => {
index % 10 === 0 ? userMarker(index) : marker(index)
)
/** Open the hover panel through the trigger and click the first prompt. */
function jumpToFirstPrompt(): void {
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
act(() => {
vi.advanceTimersByTime(300)
})
fireEvent.click(screen.getByRole('button', { name: 'prompt-0' }))
act(() => {
vi.advanceTimersByTime(300)
@@ -277,8 +277,6 @@ describe('jumping from the rail while following the end', () => {
expect(distanceFromBottom()).toBe(0)
expect(screen.queryByText(prompt) !== null).toBe(loaded)
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
await frame()
fireEvent.click(screen.getByRole('button', { name: prompt }))
await settle(60)
@@ -302,13 +300,11 @@ describe('jumping from the rail while following the end', () => {
render(<PagedTranscript holdPage={holdPage} />)
await settle(10)
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
await frame()
fireEvent.click(screen.getByRole('button', { name: 'prompt-5' }))
await frame()
// Anti-vacuous: the older page is in flight.
expect(releaseFirstPage).not.toBeNull()
// The list stays open while the pick pages in, so the reader picks again in place.
// The reader picks again while the first pick is still paging in.
fireEvent.click(screen.getByRole('button', { name: 'prompt-45' }))
await settle(40)
act(() => releaseFirstPage?.())
@@ -341,27 +337,23 @@ describe('jumping from the rail while following the end', () => {
}
async function pickUnloadedWhilePaging(prompt: string): Promise<void> {
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
await frame()
fireEvent.click(screen.getByRole('button', { name: prompt }))
await frame()
}
it('keeps the list open with the pick marked busy until its history lands', async () => {
it('marks the pick busy until its history lands', async () => {
const pages = holdFirstPage()
render(<PagedTranscript holdPage={pages.holdPage} />)
await settle(10)
await pickUnloadedWhilePaging('prompt-5')
expect(pages.asked()).toBe(1)
// The failure mode: picking closed the list, so the busy item was never seen.
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBe('true')
pages.release()
await settle(60)
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBeNull()
expect(Math.abs(rowOffsetFromViewportTop('prompt-5'))).toBeLessThanOrEqual(2)
})
@@ -382,8 +374,8 @@ describe('jumping from the rail while following the end', () => {
input()
await frame()
// Abandoning the jump settles the pick: nothing is left pulsing in an open list.
expect(screen.queryByRole('dialog')).toBeNull()
// Abandoning the jump settles the pick: nothing is left pulsing on the rail.
expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBeNull()
pages.release()
await settle(60)
@@ -401,7 +393,7 @@ describe('jumping from the rail while following the end', () => {
expect(pages.asked()).toBe(1)
// The rail forwards its wheel to the transcript: the reader is scrolling.
fireEvent.wheel(screen.getByRole('button', { name: 'Your messages' }), { deltaY: -40 })
fireEvent.wheel(screen.getByRole('toolbar', { name: 'Your messages' }), { deltaY: -40 })
await frame()
// Anti-vacuous: the wheel moved the transcript.
expect(distanceFromBottom()).toBe(40)
@@ -413,22 +405,6 @@ describe('jumping from the rail while following the end', () => {
expect(distanceFromBottom()).toBe(40)
})
it('keeps paging when the reader wheels the open message list', async () => {
const pages = holdFirstPage()
render(<PagedTranscript holdPage={pages.holdPage} />)
await settle(10)
await pickUnloadedWhilePaging('prompt-5')
expect(pages.asked()).toBe(1)
// The list scrolls itself; the transcript is not being read.
fireEvent.wheel(screen.getByRole('dialog'), { deltaY: -40 })
pages.release()
await settle(60)
expect(pages.asked()).toBeGreaterThan(1)
expect(Math.abs(rowOffsetFromViewportTop('prompt-5'))).toBeLessThanOrEqual(2)
})
it('stays at the latest message when "Jump to latest" is pressed while the jump pages', async () => {
const pages = holdFirstPage()
render(<PagedTranscript holdPage={pages.holdPage} />)
@@ -466,8 +442,6 @@ describe('jumping from the rail while following the end', () => {
render(<PagedTranscript />)
await settle(10)
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
await frame()
fireEvent.click(screen.getByRole('button', { name: 'prompt-45' }))
await frame()
@@ -556,7 +530,6 @@ describe('revealing a diff while a rail jump pages', () => {
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
const { container } = render(<DiffTranscript holdPage={holdPage} />)
fireEvent.click(screen.getByRole('button', { name: 'Your messages' }))
fireEvent.click(screen.getByRole('button', { name: 'Oldest prompt' }))
// Anti-vacuous: the older page is in flight.
expect(holdPage).toHaveBeenCalledTimes(1)
@@ -4,7 +4,7 @@ import {
} from './native-chat-appearance-style'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'
import { useNativeChatRowTypography } from './use-native-chat-row-typography'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { translate } from '@/i18n/i18n'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
@@ -47,6 +47,7 @@ import type {
NativeChatRailOutlineEntry
} from './native-chat-message-rail-items'
import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump'
import { useNativeChatRailJumpLanding } from './use-native-chat-rail-jump-landing'
import { useNativeChatReaderScrollInput } from './native-chat-reader-scroll-input'
import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest'
@@ -241,6 +242,7 @@ export function NativeChatMessageList({
showsTailRow: tailRow !== null,
isVisible,
alignToViewportTop: transcriptWindow.alignToViewportTop,
isAlignPending: transcriptWindow.isAlignPending,
scrollToEnd: transcriptWindow.scrollToEnd,
restoreScrollOffset: transcriptWindow.restoreScrollOffset,
consumeProgrammaticScroll: transcriptWindow.consumeProgrammaticScroll,
@@ -257,10 +259,10 @@ export function NativeChatMessageList({
const rail = useNativeChatMessageRail({
scrollRef,
slots,
turnRows,
virtualItems: transcriptWindow.virtualItems,
outline: railOutline
})
const servicedRailJumpRef = useRef(0)
const requestRailJump = useCallback((item: NativeChatRailItem) => {
navigationSequence.current += 1
setNavigationRequest({
@@ -314,29 +316,17 @@ export function NativeChatMessageList({
useNativeChatMessageListHandle(ref, jumpToLatest)
const readerScrollInput = useNativeChatReaderScrollInput(scrollRef, {
onReaderScroll: beginNavigation,
onTakeScroll: transcriptWindow.cancelAlign,
onLeaveEnd: readerLeavesEnd
})
// Pinning the target mounts it in the same commit, so the row exists by the time
// layout runs. Routed through `scrollMessageToTop` rather than the virtualizer
// because that is what releases the bottom pin — without it the next streamed
// token snaps the reader straight back down.
//
// Serviced once per request, then released. `slots` takes a new identity on
// every render, so an effect that merely depended on it would re-scroll to this
// row forever; and a request left standing would keep its pin, which outranks
// the diff reveal that shares it.
useLayoutEffect(() => {
if (railJump === null || servicedRailJumpRef.current === railJump.requestId) {
return
}
servicedRailJumpRef.current = railJump.requestId
const index = nativeChatSlotIndexOf(slots, railJump.messageId)
const row = scrollRef.current?.querySelector<HTMLElement>(`[data-index="${index}"]`)
if (row) {
scrollMessageToTop(row)
}
setNavigationRequest(null)
}, [railJump, scrollMessageToTop, slots])
useNativeChatRailJumpLanding({
railJump,
slots,
scrollRef,
scrollMessageToTop,
onActivate: rail.onActivate,
release: setNavigationRequest
})
const rowContext = useMemo<NativeChatTranscriptRowContext>(
() => ({
@@ -1,260 +1,147 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createRef, useImperativeHandle, useState } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatMessageRail } from './NativeChatMessageRail'
import { useNativeChatMessageRail } from './use-native-chat-message-rail'
afterEach(cleanup)
const items = Array.from({ length: 3 }, (_, index) => ({
id: `prompt-${index}`,
text: `Prompt ${index}`,
slotIndex: index,
hasImages: false
}))
const overflowItems = Array.from({ length: 20 }, (_, index) => ({
id: `overflow-prompt-${index}`,
text: `Overflow prompt ${index}`,
slotIndex: index,
hasImages: false
}))
const NO_SLOTS: never[] = []
const NO_ROWS = { messages: [], turnKeys: [] }
function retainClosingPopover(): ReturnType<typeof vi.spyOn> {
const getStyle = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, ...args) => {
const style = getStyle(element, ...args)
if (element.getAttribute('data-slot') !== 'popover-content') {
return style
}
return new Proxy(style, {
get: (target, property) =>
property === 'animationName'
? element.getAttribute('data-state') === 'closed'
? 'exit'
: 'enter'
: // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy trap passes CSSStyleDeclaration properties through unchanged.
Reflect.get(target, property)
})
/** The rail over the real hook, on a thread known only from the host's outline.
* The first two prompts have replies; past 20 the ticks are sampled. */
function Rail({
count = 3,
activeIndex,
scroller,
...rest
}: Partial<React.ComponentProps<typeof NativeChatMessageRail>> & {
count?: number
activeIndex?: number
scroller?: HTMLDivElement
}): React.JSX.Element {
const [scrollRef] = useState(() => ({ current: scroller ?? document.createElement('div') }))
const [outline] = useState(() =>
Array.from({ length: count }, (_, index) => ({
id: `prompt-${index}`,
text: `Prompt ${index}`,
hasImages: false,
reply: index < 2 ? `Reply ${index}` : undefined
}))
)
const rail = useNativeChatMessageRail({
scrollRef,
slots: NO_SLOTS,
turnRows: NO_ROWS,
virtualItems: NO_SLOTS,
outline
})
}
const unloadedItems = [
{ id: 'unloaded-prompt', text: 'Unloaded prompt', slotIndex: null, hasImages: false },
...items
]
/** Stands in for the transcript: an unloaded pick stays pending until settled. */
function PagingRail({ ref }: { ref?: React.Ref<{ settle: () => void }> }): React.JSX.Element {
const [pendingId, setPendingId] = useState<string | null>(null)
useImperativeHandle(ref, () => ({ settle: () => setPendingId(null) }), [])
const { onActivate } = rail
useEffect(() => {
if (activeIndex !== undefined) {
onActivate(`prompt-${activeIndex}`)
}
}, [activeIndex, onActivate])
return (
<NativeChatMessageRail
rail={{ items: unloadedItems, ticks: unloadedItems, activeId: null, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={(item) => setPendingId(item.slotIndex === null ? item.id : null)}
pendingId={pendingId}
/>
<>
<input aria-label="Composer" />
<NativeChatMessageRail rail={rail} scrollRef={scrollRef} onSelect={vi.fn()} {...rest} />
</>
)
}
const tick = (name: string): HTMLElement => screen.getByRole('button', { name })
const preview = (): HTMLElement | null =>
document.querySelector<HTMLElement>('[data-slot="hover-card-content"]')
describe('message rail interaction', () => {
it('keeps the list open on an unloaded pick until its jump settles', async () => {
const user = userEvent.setup()
const paging = createRef<{ settle: () => void }>()
render(<PagingRail ref={paging} />)
await user.hover(screen.getByRole('button', { name: 'Your messages' }))
await screen.findByRole('dialog')
it('previews only the hovered tick: its message and the reply to it', async () => {
render(<Rail />)
fireEvent.pointerEnter(tick('Prompt 1'), { pointerType: 'mouse' })
await waitFor(() => expect(preview()).not.toBeNull())
expect(preview()?.textContent).toBe('Prompt 1Reply 1')
await user.click(screen.getByRole('button', { name: 'Unloaded prompt' }))
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Unloaded prompt' }).getAttribute('aria-busy')).toBe(
'true'
)
act(() => paging.current?.settle())
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
fireEvent.pointerEnter(tick('Prompt 0'), { pointerType: 'mouse' })
await waitFor(() => expect(preview()?.textContent).toBe('Prompt 0Reply 0'))
})
it('closes a list held for a pending pick on Escape', async () => {
const user = userEvent.setup()
render(<PagingRail />)
await user.hover(screen.getByRole('button', { name: 'Your messages' }))
await screen.findByRole('dialog')
await user.click(screen.getByRole('button', { name: 'Unloaded prompt' }))
expect(screen.getByRole('dialog')).toBeTruthy()
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
it('previews the message alone while the agent has not replied', async () => {
render(<Rail />)
fireEvent.pointerEnter(tick('Prompt 2'), { pointerType: 'mouse' })
await waitFor(() => expect(preview()).not.toBeNull())
expect(preview()?.textContent).toBe('Prompt 2')
})
it('opens from the keyboard, reaches prompts, jumps, and restores focus', async () => {
const user = userEvent.setup()
it('jumps to the clicked tick', async () => {
const select = vi.fn()
render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[12].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={select}
/>
)
const trigger = screen.getByRole('button', { name: 'Your messages' })
await user.tab()
expect(document.activeElement).toBe(trigger)
await user.keyboard('{Enter}')
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: 'Overflow prompt 12' })
)
)
await user.keyboard('{Enter}')
expect(select).toHaveBeenCalledWith(overflowItems[12])
await waitFor(() => expect(document.activeElement).toBe(trigger))
expect(screen.queryByRole('dialog')).toBeNull()
await user.keyboard('{Enter}')
await user.keyboard('{Escape}')
await waitFor(() => expect(document.activeElement).toBe(trigger))
expect(screen.queryByRole('dialog')).toBeNull()
render(<Rail onSelect={select} />)
await userEvent.setup().click(tick('Prompt 1'))
expect(select).toHaveBeenCalledWith(expect.objectContaining({ id: 'prompt-1' }))
})
it('focuses the current prompt when a hover preview becomes interactive', async () => {
render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[12].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: 'Your messages' })
fireEvent.pointerEnter(trigger, { pointerType: 'mouse' })
await screen.findByRole('dialog')
fireEvent.click(trigger)
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Overflow prompt 12' }))
})
it('focuses the first prompt on direct open when no prompt is current', async () => {
const user = userEvent.setup()
render(
<NativeChatMessageRail
rail={{ items: overflowItems, ticks: overflowItems, activeId: null, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: 'Your messages' })
trigger.focus()
await user.keyboard('{Enter}')
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Overflow prompt 0' }))
)
})
it('refocuses the current prompt when closed content is reopened before unmount', async () => {
const styleSpy = retainClosingPopover()
const user = userEvent.setup()
try {
render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[12].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: 'Your messages' })
trigger.focus()
await user.keyboard('{Enter}')
await user.keyboard('{Escape}')
await waitFor(() =>
expect(
document.querySelector('[data-slot="popover-content"]')?.getAttribute('data-state')
).toBe('closed')
)
trigger.focus()
fireEvent.click(trigger)
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: 'Overflow prompt 12' })
)
)
} finally {
styleSpy.mockRestore()
}
})
it('preserves interactive focus when the current prompt changes', async () => {
const user = userEvent.setup()
const { rerender } = render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[12].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: 'Your messages' })
trigger.focus()
await user.keyboard('{Enter}')
const focusedPrompt = screen.getByRole('button', { name: 'Overflow prompt 12' })
expect(document.activeElement).toBe(focusedPrompt)
rerender(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[13].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
expect(document.activeElement).toBe(focusedPrompt)
})
it('keeps focus in the transcript while a hover preview opens and closes', async () => {
render(
<>
<input aria-label="Composer" />
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: null, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
</>
)
it('keeps focus where it was while a hover preview opens and closes', async () => {
render(<Rail />)
const composer = screen.getByRole('textbox')
composer.focus()
const trigger = screen.getByRole('button', { name: 'Your messages' })
fireEvent.pointerEnter(trigger, { pointerType: 'mouse' })
await screen.findByRole('dialog')
const rail = screen.getByRole('toolbar', { name: 'Your messages' })
fireEvent.pointerEnter(rail, { pointerType: 'mouse' })
fireEvent.pointerEnter(tick('Prompt 0'), { pointerType: 'mouse' })
await waitFor(() => expect(preview()).not.toBeNull())
expect(document.activeElement).toBe(composer)
fireEvent.pointerLeave(trigger, { pointerType: 'mouse' })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
fireEvent.pointerLeave(rail, { pointerType: 'mouse' })
await waitFor(() => expect(preview()).toBeNull())
expect(document.activeElement).toBe(composer)
})
it('is one tab stop on the current tick, walked by arrow keys, each previewing itself', async () => {
const user = userEvent.setup()
const select = vi.fn()
render(<Rail activeIndex={1} onSelect={select} />)
screen.getByRole('textbox').focus()
await user.tab()
expect(document.activeElement).toBe(tick('Prompt 1'))
await waitFor(() => expect(preview()).not.toBeNull())
expect(preview()?.textContent).toBe('Prompt 1Reply 1')
await user.keyboard('{ArrowUp}')
expect(document.activeElement).toBe(tick('Prompt 0'))
await waitFor(() => expect(preview()?.textContent).toBe('Prompt 0Reply 0'))
await user.keyboard('{End}{Enter}')
expect(select).toHaveBeenCalledWith(expect.objectContaining({ id: 'prompt-2' }))
await user.keyboard('{Escape}')
await waitFor(() => expect(preview()).toBeNull())
expect(document.activeElement).toBe(tick('Prompt 2'))
await user.tab()
expect(screen.getByRole('toolbar').contains(document.activeElement)).toBe(false)
})
// A long thread draws only some ticks; the keyboard still reaches every message,
// and the tick it lands on stays drawn while it holds focus.
it('walks every message with the arrow keys, sampled onto the rail or not', async () => {
const user = userEvent.setup()
render(<Rail count={60} />)
// Anti-vacuous: the second message has no tick until the keyboard reaches it.
expect(screen.queryByRole('button', { name: 'Prompt 1' })).toBeNull()
screen.getByRole('textbox').focus()
await user.tab()
expect(document.activeElement).toBe(tick('Prompt 0'))
await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(tick('Prompt 1'))
await waitFor(() => expect(preview()?.textContent).toBe('Prompt 1Reply 1'))
await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(tick('Prompt 2'))
})
it('marks a tick whose history is still paging in as busy', () => {
render(<Rail pendingId="prompt-0" />)
expect(tick('Prompt 0').getAttribute('aria-busy')).toBe('true')
expect(tick('Prompt 1').getAttribute('aria-busy')).toBeNull()
})
it.each([
[0, 7],
[1, 112],
@@ -262,205 +149,11 @@ describe('message rail interaction', () => {
])('forwards wheel delta mode %i', (deltaMode, expected) => {
const element = document.createElement('div')
Object.defineProperty(element, 'clientHeight', { value: 400 })
render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: null, visible: true }}
scrollRef={{ current: element }}
onSelect={vi.fn()}
/>
)
fireEvent.wheel(screen.getByRole('button', { name: 'Your messages' }), { deltaY: 7, deltaMode })
render(<Rail scroller={element} />)
fireEvent.wheel(screen.getByRole('toolbar', { name: 'Your messages' }), {
deltaY: 7,
deltaMode
})
expect(element.scrollTop).toBe(expected)
})
// happy-dom has no layout, so these pin which row the panel scrolls to, not
// the resulting offset. The offset itself only exists in a real browser.
describe('opening position', () => {
const scrolled: Element[] = []
let scrollIntoView: ReturnType<typeof vi.spyOn>
beforeEach(() => {
scrolled.length = 0
scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(function mockScrollIntoView(this: Element) {
scrolled.push(this)
})
})
afterEach(() => scrollIntoView.mockRestore())
it('scrolls the panel to the message the reader is on', async () => {
render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
pointerType: 'mouse'
})
await screen.findByRole('dialog')
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
})
it('rechecks the current row when messages are inserted before it', async () => {
const { rerender } = render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
pointerType: 'mouse'
})
await screen.findByRole('dialog')
scrolled.length = 0
const shiftedItems = [
{ id: 'older-prompt', text: 'Older prompt', slotIndex: 0, hasImages: false },
...items.map((item) => ({ ...item, slotIndex: item.slotIndex + 1 }))
]
rerender(
<NativeChatMessageRail
rail={{ items: shiftedItems, ticks: shiftedItems, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
})
it('rechecks the current row when the same number of messages is reordered', async () => {
const { rerender } = render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
pointerType: 'mouse'
})
await screen.findByRole('dialog')
scrolled.length = 0
const reorderedItems = [items[2], items[0], items[1]]
rerender(
<NativeChatMessageRail
rail={{
items: reorderedItems,
ticks: reorderedItems,
activeId: items[2].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
})
// Pressing an item focuses it, which turns a hover preview interactive. Scrolling
// the lit row into view at that moment moved the list under the pointer, so the
// release landed on the list instead of the pressed item and the click was lost.
it('does not move the list or focus when a press makes a hover preview interactive', async () => {
const user = userEvent.setup()
const select = vi.fn()
render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[19].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={select}
/>
)
await user.hover(screen.getByRole('button', { name: 'Your messages' }))
await screen.findByRole('dialog')
// Anti-vacuous: opening revealed the lit row.
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Overflow prompt 19' })])
scrolled.length = 0
const older = screen.getByRole('button', { name: 'Overflow prompt 0' })
await user.pointer({ keys: '[MouseLeft>]', target: older })
expect(scrolled).toEqual([])
expect(document.activeElement).toBe(older)
await user.pointer({ keys: '[/MouseLeft]', target: older })
expect(select).toHaveBeenCalledWith(overflowItems[0])
})
it('does not move the list while a picked message pages in', async () => {
const { rerender } = render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
pointerType: 'mouse'
})
await screen.findByRole('dialog')
// Anti-vacuous: opening revealed the lit row.
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
scrolled.length = 0
// A landed page gives every row a new slot while the pick is still pending.
const pagedItems = items.map((item) => ({ ...item, slotIndex: item.slotIndex + 5 }))
rerender(
<NativeChatMessageRail
rail={{ items: pagedItems, ticks: pagedItems, activeId: items[2].id, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
pendingId={items[0].id}
/>
)
expect(scrolled).toEqual([])
})
it('reveals the lit row when the list opens from the keyboard', async () => {
const user = userEvent.setup()
render(
<NativeChatMessageRail
rail={{
items: overflowItems,
ticks: overflowItems,
activeId: overflowItems[12].id,
visible: true
}}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
screen.getByRole('button', { name: 'Your messages' }).focus()
await user.keyboard('{Enter}')
const lit = screen.getByRole('button', { name: 'Overflow prompt 12' })
await waitFor(() => expect(document.activeElement).toBe(lit))
expect(scrolled).toContain(lit)
})
it('leaves the panel alone when no message is lit', async () => {
render(
<NativeChatMessageRail
rail={{ items, ticks: items, activeId: null, visible: true }}
scrollRef={{ current: document.createElement('div') }}
onSelect={vi.fn()}
/>
)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
pointerType: 'mouse'
})
await screen.findByRole('dialog')
expect(scrolled).toEqual([])
})
})
})
@@ -1,11 +1,15 @@
// The rail itself: a column of ticks down the right edge of the transcript, one
// per user message, with a hover panel that previews them and jumps on click.
// per user message. Each tick previews its own message and the agent's reply
// while hovered or focused, and jumps to the message on click.
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { memo, useLayoutEffect, useRef, useState } from 'react'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { NativeChatRailItem } from './native-chat-message-rail-items'
import {
NATIVE_CHAT_RAIL_ROOMY_TICKS,
type NativeChatRailItem
} from './native-chat-message-rail-items'
import type { NativeChatMessageRailState } from './use-native-chat-message-rail'
const WHEEL_DELTA_LINE = 1
@@ -22,82 +26,25 @@ function railItemLabel(item: NativeChatRailItem): string {
: translate('components.native-chat.railEmptyMessage', 'Message')
}
type NativeChatMessageRailMode = 'hover' | 'interactive' | null
/** Where the card sits, and the reply it showed, latched on close for its exit animation. */
type NativeChatRailPreviewTarget = { id: string; top: number; reply: string }
function NativeChatMessageRailItems({
mode,
items,
activeId,
pendingId,
onSelect
}: {
mode: NativeChatMessageRailMode
items: readonly NativeChatRailItem[]
activeId: string | null
pendingId: string | null
onSelect: (item: NativeChatRailItem) => void
}): React.JSX.Element {
const listRef = useRef<HTMLUListElement>(null)
const currentItemRef = useRef<HTMLButtonElement>(null)
const previousMode = useRef<NativeChatMessageRailMode>(null)
/** From a tick's centre up to the card's top edge, so the card's first line meets
* the tick: the card's `p-4` plus half a `text-xs leading-snug` line. */
const PREVIEW_FIRST_LINE_PX = 24
const open = mode !== null
// Reveal the lit row when the list opens or its rows shift — not when a hover
// preview turns interactive, which a press on an item does: moving the list
// then slides the item out from under the pointer and the click is lost. Nor
// while a picked item pages in: each landed page shifts the rows under it.
useLayoutEffect(() => {
if (open && activeId !== null && items.length > 0 && pendingId === null) {
currentItemRef.current?.scrollIntoView({ block: 'nearest' })
}
}, [activeId, items, open, pendingId])
// Entering interactive from the rail moves focus into the list; entering it by
// focusing an item already put focus where the reader chose.
useLayoutEffect(() => {
if (
mode === 'interactive' &&
previousMode.current !== 'interactive' &&
!listRef.current?.contains(document.activeElement)
) {
const focusTarget =
(activeId === null ? null : currentItemRef.current) ??
listRef.current?.querySelector<HTMLButtonElement>('button')
focusTarget?.focus({ preventScroll: true })
}
previousMode.current = mode
}, [activeId, mode])
return (
<ul ref={listRef} className="scrollbar-sleek max-h-64 overflow-y-auto overflow-x-hidden">
{items.map((item) => (
<li key={item.id}>
<button
type="button"
ref={item.id === activeId ? currentItemRef : undefined}
onClick={() => onSelect(item)}
aria-current={item.id === activeId ? 'true' : undefined}
aria-busy={item.id === pendingId ? true : undefined}
data-current={item.id === activeId}
className={cn(
'flex w-full cursor-pointer rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
item.id === activeId && 'bg-accent'
)}
>
<span
className={cn(
'line-clamp-2 text-xs leading-snug',
item.id === activeId ? 'text-foreground' : 'text-muted-foreground',
item.id === pendingId && 'animate-pulse'
)}
>
{railItemLabel(item)}
</span>
</button>
</li>
))}
</ul>
)
/** How far a navigation key moves through the messages; null for any other key. */
function railKeyStep(key: string, index: number, count: number): number | null {
if (key === 'ArrowDown') {
return index + 1
}
if (key === 'ArrowUp') {
return index - 1
}
if (key === 'Home') {
return 0
}
return key === 'End' ? count - 1 : null
}
export const NativeChatMessageRail = memo(function NativeChatMessageRail({
@@ -115,75 +62,80 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
/** A tick whose older history is still paging in. */
pendingId?: string | null
}): React.JSX.Element | null {
// Hover preserves focus; activation enters the focus-managed prompt picker.
const [mode, setMode] = useState<NativeChatMessageRailMode>(null)
// A pick that pages history in keeps the list open, its item pulsing, until the
// jump lands or is abandoned; then it closes as any pick does.
const [heldId, setHeldId] = useState<string | null>(null)
if (heldId !== null && (heldId !== pendingId || mode === null)) {
setHeldId(null)
setMode(null)
}
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const restoreFocus = useRef(false)
const open = mode !== null
const cancelClose = (): void => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current)
const [target, setTarget] = useState<NativeChatRailPreviewTarget | null>(null)
const railRef = useRef<HTMLDivElement>(null)
/** A message an arrow key asked to focus, whose tick may not be drawn until the next render. */
const focusRequest = useRef<string | null>(null)
const { ticks, items, focusId } = rail
useLayoutEffect(() => {
if (focusRequest.current !== focusId) {
return
}
closeTimer.current = null
}
const leavePreview = (): void => {
cancelClose()
if (mode === 'hover') {
closeTimer.current = setTimeout(() => setMode(null), 120)
const index = ticks.findIndex((item) => item.id === focusId)
if (index !== -1) {
focusRequest.current = null
railRef.current?.querySelectorAll('button')[index]?.focus()
}
}
useEffect(
() => () => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current)
}
},
[]
)
}, [focusId, ticks])
if (!rail.visible) {
return null
}
const previewed = target === null ? null : (ticks.find((item) => item.id === target.id) ?? null)
const previewedId = rail.previewId !== null && previewed ? previewed.id : null
const reply = previewedId === null ? (target?.reply ?? '') : rail.previewReply
// One tab stop for the whole rail; arrow keys walk it from there.
const tabId =
[focusId, rail.activeId].find((id) => ticks.some((item) => item.id === id)) ?? ticks[0]?.id
const show = (item: NativeChatRailItem, element: HTMLElement): void => {
setTarget({ id: item.id, top: element.offsetTop + element.offsetHeight / 2, reply: '' })
rail.onPreview(item.id)
}
const close = (): void => {
setTarget((current) => current && { ...current, reply: rail.previewReply })
rail.onPreview(null)
}
return (
<Popover
open={open}
onOpenChange={(open) => {
cancelClose()
if (open) {
restoreFocus.current = true
}
setMode(open ? 'interactive' : null)
}}
// One card for the whole rail, moved to the tick it previews: a card per tick
// would animate out and in again on every step between neighbours. The ticks
// open it; the card's own hover, blur and Escape handling closes it.
<HoverCard
open={previewedId !== null}
onOpenChange={(next) => !next && close()}
openDelay={0}
closeDelay={100}
>
<PopoverTrigger asChild>
<button
type="button"
<HoverCardTrigger asChild>
<div
ref={railRef}
role="toolbar"
data-native-chat-rail
aria-label={translate('components.native-chat.railLabel', 'Your messages')}
onPointerEnter={(event) => {
if (event.pointerType === 'touch') {
aria-orientation="vertical"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
rail.onFocusItem(null)
}
}}
onKeyDown={(event) => {
// Through every message, not only the drawn ticks: a long thread samples them.
const next = railKeyStep(
event.key,
items.findIndex((item) => item.id === tabId),
items.length
)
if (next === null) {
return
}
cancelClose()
if (mode === null) {
restoreFocus.current = false
}
setMode((current) => current ?? 'hover')
}}
onPointerLeave={leavePreview}
onClick={(event) => {
cancelClose()
if (mode === 'hover') {
event.preventDefault()
restoreFocus.current = true
setMode('interactive')
event.preventDefault()
const item = items[next]
if (item) {
focusRequest.current = item.id
rail.onFocusItem(item.id)
}
}}
// The rail overlays the transcript without being inside it, so a wheel
@@ -206,56 +158,70 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
onReaderScroll?.(event.deltaY * scale)
element.scrollTop += event.deltaY * scale
}}
className="group/rail absolute inset-y-0 right-[14px] z-10 flex w-4 cursor-default flex-col items-center justify-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute top-1/2 right-2.5 z-10 flex w-6 -translate-y-1/2 flex-col"
>
{rail.ticks.map((item) => (
<span
key={item.id}
aria-hidden
className={cn(
'h-[3px] shrink-0 rounded-full transition-all duration-150',
item.id === rail.activeId
? 'w-5 bg-foreground/30 group-hover/rail:bg-foreground/70'
: 'w-3 bg-foreground/10 group-hover/rail:bg-foreground/25'
)}
/>
))}
</button>
</PopoverTrigger>
<PopoverContent
{ticks.map((item) => {
// The pointer or keyboard owns the fill while a preview is open; otherwise
// the fill reports the scroll position.
const lit = item.id === (previewedId ?? rail.activeId)
return (
<button
key={item.id}
type="button"
tabIndex={item.id === tabId ? 0 : -1}
aria-label={railItemLabel(item)}
aria-current={item.id === rail.activeId ? 'true' : undefined}
aria-busy={item.id === pendingId ? true : undefined}
onPointerEnter={(event) => {
if (event.pointerType !== 'touch') {
show(item, event.currentTarget)
}
}}
onFocus={(event) => {
rail.onFocusItem(item.id)
// A click focuses the tick too, and must not reopen the preview it dismissed.
if (event.currentTarget.matches(':focus-visible')) {
show(item, event.currentTarget)
}
}}
onClick={() => onSelect(item)}
// Padding, not a gap, so the pointer never falls between two ticks.
className={cn(
'flex w-full shrink-0 cursor-pointer items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
ticks.length > NATIVE_CHAT_RAIL_ROOMY_TICKS ? 'py-0.5' : 'py-1'
)}
>
<span
aria-hidden
className={cn(
'h-[3px] rounded-full transition-[width,background-color] duration-150',
lit ? 'w-5 bg-foreground' : 'w-3 bg-foreground/50',
item.id === pendingId && 'animate-pulse'
)}
/>
</button>
)
})}
</div>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="center"
aria-label={translate('components.native-chat.railLabel', 'Your messages')}
className="w-72 p-1"
onPointerEnter={cancelClose}
onPointerLeave={leavePreview}
onFocusCapture={() => {
cancelClose()
restoreFocus.current = true
setMode('interactive')
}}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => {
if (!restoreFocus.current) {
event.preventDefault()
}
}}
align="start"
alignOffset={(target?.top ?? 0) - PREVIEW_FIRST_LINE_PX}
sideOffset={8}
className="w-72"
>
<NativeChatMessageRailItems
mode={mode}
items={rail.items}
activeId={rail.activeId}
pendingId={pendingId}
onSelect={(item) => {
onSelect(item)
if (item.slotIndex === null) {
setHeldId(item.id)
} else {
setMode(null)
}
}}
/>
</PopoverContent>
</Popover>
{previewed ? (
<div className="flex flex-col gap-1">
<p className="line-clamp-2 text-xs leading-snug text-foreground">
{railItemLabel(previewed)}
</p>
{reply.length > 0 ? (
<p className="line-clamp-3 text-xs leading-snug text-muted-foreground">{reply}</p>
) : null}
</div>
) : null}
</HoverCardContent>
</HoverCard>
)
})
@@ -82,6 +82,22 @@ describe('active rail item', () => {
).toBe('u2')
})
// A message jumped to near the end sits at the top edge a few pixels short of
// the bottom: it is what is being read, not the newest turn.
it('lights the turn at the fold when parked just above the bottom', () => {
expect(
findActiveNativeChatRailItem({
slots: [{ turnKey: 'u1' }, { turnKey: 'u1' }, { turnKey: 'u2' }, { turnKey: 'u2' }],
virtualItems: rows(4),
// 30px short of the end, with a `u1` row at the top edge.
scrollTop: 100,
clientHeight: VIEWPORT,
scrollHeight: 430,
previousActiveId: null
})
).toBe('u1')
})
it('lights nothing above the first prompt', () => {
expect(
findActiveNativeChatRailItem({
@@ -14,7 +14,7 @@
// `item.start - scrollMargin` inside a sizer sitting `scrollMargin` down), which is
// the same coordinate space as `scrollTop`.
import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
import { NATIVE_CHAT_FOLLOW_REARM_PX } from './native-chat-autoscroll'
/** The virtualizer's item, restated so this module needs nothing from the lib. */
export type NativeChatRailVirtualItem = {
@@ -55,7 +55,9 @@ export function findActiveNativeChatRailItem({
// Pinned to the bottom the newest turn is what is being read, whatever happens
// to sit at the top edge — a short last turn would otherwise light its predecessor.
const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX
// The follow band, not the wider jump-affordance one: a message jumped to near
// the end sits at the top edge a few pixels short of it, and is what is being read.
const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_FOLLOW_REARM_PX
if (atBottom) {
const last = virtualItems.at(-1)
return last === undefined ? previousActiveId : railTickOf(slots[last.index])
@@ -63,7 +63,8 @@ describe('nextFollowingEnd', () => {
following: true,
programmatic: false,
geometry: parkedAbove(0),
previousDistanceFromEnd: 400
previousDistanceFromEnd: 400,
settling: false
}
it('follows when the reader reaches the end', () => {
@@ -89,7 +90,8 @@ describe('nextFollowingEnd', () => {
following: false,
programmatic: true,
geometry: parkedAbove(distance),
previousDistanceFromEnd: 400
previousDistanceFromEnd: 400,
settling: false
})
).toBe(false)
}
@@ -108,7 +110,8 @@ describe('nextFollowingEnd', () => {
const detached = {
following: false,
programmatic: false,
previousDistanceFromEnd: 400
previousDistanceFromEnd: 400,
settling: false
}
expect(
nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX) })
@@ -122,7 +125,12 @@ describe('nextFollowingEnd', () => {
// move a pixel or two and are unmarked: read as the reader arriving, they
// re-armed follow and the next frame rebased the view, cancelling the scroll.
it('does not reattach a detached reader who is moving away from the end', () => {
const leaving = { following: false, programmatic: false, previousDistanceFromEnd: 0 }
const leaving = {
following: false,
programmatic: false,
previousDistanceFromEnd: 0,
settling: false
}
expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(0.3) })).toBe(false)
expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(2) })).toBe(false)
// Arriving from above still reattaches, and standing still at the end does too.
@@ -132,6 +140,18 @@ describe('nextFollowingEnd', () => {
expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(0) })).toBe(true)
})
it('does not reattach a jump still travelling when shrinking content clamps it onto the end', () => {
const clamped = {
following: false,
programmatic: false,
previousDistanceFromEnd: 35,
geometry: parkedAbove(0)
}
expect(nextFollowingEnd({ ...clamped, settling: true })).toBe(false)
// Anti-vacuous: the same offset reattaches a reader with no jump under way.
expect(nextFollowingEnd({ ...clamped, settling: false })).toBe(true)
})
it('keeps a following reader through a small move up inside the band', () => {
expect(
nextFollowingEnd({ ...following, previousDistanceFromEnd: 0, geometry: parkedAbove(2) })
@@ -50,11 +50,15 @@ export type FollowIntent = {
geometry: ScrollGeometry
/** Distance from the end at the previous scroll event. */
previousDistanceFromEnd: number
/** Whether a jump to a row is still travelling there. Rows it passes measure
* shorter than estimated, the content shrinks, and the browser clamps the view
* onto the end: that is the end arriving, not the reader. */
settling: boolean
}
/** Passive offsets preserve following; detached views rearm only while closing on the actual tail. */
export function nextFollowingEnd(intent: FollowIntent): boolean {
if (intent.following || intent.programmatic) {
if (intent.following || intent.programmatic || intent.settling) {
return intent.following
}
if (distanceFromBottom(intent.geometry) > intent.previousDistanceFromEnd) {
@@ -6,8 +6,10 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
import {
buildNativeChatRailItems,
mergeNativeChatRailOutline,
nativeChatRailReplyPreview,
nativeChatRailTickCapacity,
selectNativeChatRailTicks,
NATIVE_CHAT_RAIL_MAX_TICKS,
NATIVE_CHAT_RAIL_ROOMY_TICKS,
type NativeChatRailItem
} from './native-chat-message-rail-items'
@@ -110,40 +112,118 @@ describe('rail items', () => {
})
})
describe('rail reply preview', () => {
const outline = [
{ id: 'old-1', text: 'first', hasImages: false, reply: 'Host reply 1' },
{ id: 'old-2', text: 'second', hasImages: false, reply: 'Host reply 2, cut off early' }
]
const rowsOf = (messages: NativeChatMessage[], turnKeys: (string | undefined)[]) => ({
messages,
turnKeys
})
it("reads a loaded message its turn's reply, past a steer into the same turn", () => {
const rows = rowsOf(
[
text('u1', 'ask', 'user'),
text('a1', 'Looking.'),
text('s1', 'also', 'user'),
text('a2', 'Done.')
],
['u1', 'u1', 'u1', 'u1']
)
expect(nativeChatRailReplyPreview(rows, [], 'u1')).toBe('Done.')
expect(nativeChatRailReplyPreview(rows, [], 's1')).toBe('Done.')
expect(nativeChatRailReplyPreview(rows, [], 'gone')).toBe('')
})
it('reads unloaded history its reply from the host', () => {
const rows = rowsOf([text('u1', 'ask', 'user'), text('a1', 'Done.')], ['u1', 'u1'])
const items = mergeNativeChatRailOutline(outline, [])
expect(nativeChatRailReplyPreview(rows, items, 'old-1')).toBe('Host reply 1')
expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Host reply 2, cut off early')
})
// A long live turn scrolls its own prompt out of the loaded window; the host's
// reply for it was cut when the outline was read, the loaded rows are current.
it('prefers the loaded end of a turn whose prompt is no longer loaded', () => {
const rows = rowsOf(
[text('a0', 'Still going, latest words.'), text('u1', 'ask', 'user')],
['old-2', 'u1']
)
const items = mergeNativeChatRailOutline(outline, [])
expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Still going, latest words.')
expect(nativeChatRailReplyPreview(rows, items, 'old-1')).toBe('Host reply 1')
})
// A steer is in the turn it was sent into, under another message's key: without
// the host naming that turn it would keep the host's reply while its turn's own
// prompt, beside it on the rail, showed the loaded one.
it("reads an unloaded steer the loaded end of its turn, as its turn's prompt", () => {
const rows = rowsOf([text('a0', 'Still going, latest words.')], ['old-2'])
const items = mergeNativeChatRailOutline(
[
...outline,
{ id: 'steer', text: 'also this', hasImages: false, reply: 'Stale.', turnKey: 'old-2' }
],
[]
)
expect(nativeChatRailReplyPreview(rows, items, 'steer')).toBe('Still going, latest words.')
expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Still going, latest words.')
})
})
describe('rail tick sampling', () => {
it('keeps every tick while the thread fits', () => {
const items = railItems(NATIVE_CHAT_RAIL_MAX_TICKS)
expect(selectNativeChatRailTicks({ items, activeId: null })).toBe(items)
const items = railItems(NATIVE_CHAT_RAIL_ROOMY_TICKS)
expect(selectNativeChatRailTicks({ items, keepIds: [] })).toBe(items)
})
// A tick is the only way to its message, so a tall transcript draws more of them.
it('fits more ticks into a taller viewport, never fewer than the roomy count', () => {
const items = railItems(60)
expect(nativeChatRailTickCapacity(0)).toBe(NATIVE_CHAT_RAIL_ROOMY_TICKS)
const maxTicks = nativeChatRailTickCapacity(800)
expect(maxTicks).toBeGreaterThanOrEqual(60)
expect(selectNativeChatRailTicks({ items, keepIds: [], maxTicks })).toBe(items)
expect(selectNativeChatRailTicks({ items, keepIds: [], maxTicks: 30 })).toHaveLength(30)
})
it('caps a long thread and keeps both ends', () => {
const items = railItems(120)
const ticks = selectNativeChatRailTicks({ items, activeId: null })
expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_MAX_TICKS)
const ticks = selectNativeChatRailTicks({ items, keepIds: [] })
expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS)
expect(ticks[0]?.id).toBe('m0')
expect(ticks.at(-1)?.id).toBe('m119')
})
it('always includes the active tick', () => {
const items = railItems(120)
const ticks = selectNativeChatRailTicks({ items, activeId: 'm7' })
const ticks = selectNativeChatRailTicks({ items, keepIds: ['m7'] })
expect(ticks.map((tick) => tick.id)).toContain('m7')
expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_MAX_TICKS)
expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS)
})
// Losing an end would make the rail claim the conversation starts or stops
// somewhere it doesn't, so the eviction has to fall on a neighbour instead.
it('evicts a neighbour rather than an end when the active tick is near one', () => {
const items = railItems(120)
const ticks = selectNativeChatRailTicks({ items, activeId: 'm1' })
const ticks = selectNativeChatRailTicks({ items, keepIds: ['m1'] })
const ids = ticks.map((tick) => tick.id)
expect(ids).toContain('m0')
expect(ids).toContain('m1')
expect(ids).toContain('m119')
})
// A previewed or focused tick must not be resampled away as the reader scrolls.
it('keeps every named tick at once, within the cap', () => {
const ticks = selectNativeChatRailTicks({ items: railItems(120), keepIds: ['m7', 'm8', null] })
expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS)
expect(ticks.map((tick) => tick.id)).toEqual(expect.arrayContaining(['m0', 'm7', 'm8', 'm119']))
})
it('returns ticks in thread order', () => {
const ticks = selectNativeChatRailTicks({ items: railItems(120), activeId: 'm63' })
const ticks = selectNativeChatRailTicks({ items: railItems(120), keepIds: ['m63'] })
const indexes = ticks.map((tick) => tick.slotIndex ?? -1)
expect(indexes).toEqual([...indexes].sort((left, right) => left - right))
})
@@ -6,12 +6,28 @@
// compared against a virtual item without a second lookup table. Messages older
// than the loaded window come from the host's outline and have no slot yet.
import { nativeChatUserMessagePreview } from '../../../../shared/agent-session-conversation-outline'
import {
nativeChatTurnReplyPreviews,
nativeChatUserMessagePreview
} from '../../../../shared/agent-session-conversation-outline'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
/** Ticks past this are sampled away: a taller rail than the viewport cannot be
* read at a glance, which is the only thing the rail is for. */
export const NATIVE_CHAT_RAIL_MAX_TICKS = 20
/** Ticks up to this count sit at the roomy pitch; past it they pack tight. */
export const NATIVE_CHAT_RAIL_ROOMY_TICKS = 20
/** The tight pitch as the rail draws it: a 3px tick inside `py-0.5`. */
const RAIL_TIGHT_PITCH_PX = 7
const RAIL_VIEWPORT_SHARE = 0.7
/** How many ticks the rail may draw. A tick is the only way to its message, so
* the rail packs as many as a share of the viewport holds before sampling any
* away: a rail taller than that cannot be read at a glance. */
export function nativeChatRailTickCapacity(viewportHeight: number): number {
return Math.max(
NATIVE_CHAT_RAIL_ROOMY_TICKS,
Math.floor((viewportHeight * RAIL_VIEWPORT_SHARE) / RAIL_TIGHT_PITCH_PX)
)
}
export type NativeChatRailItem = {
id: string
@@ -21,6 +37,10 @@ export type NativeChatRailItem = {
/** Preview prose, whitespace collapsed. Empty when the message is images only. */
text: string
hasImages: boolean
/** The host's preview of the agent's reply, on an outline item only. */
reply?: string
/** On an outline item that did not open its turn: the message that did. */
turnKey?: string
}
/** A user message older than the loaded window, oldest first. */
@@ -28,6 +48,36 @@ export type NativeChatRailOutlineEntry = {
id: string
text: string
hasImages: boolean
reply?: string
turnKey?: string
}
/** The transcript's rows and each one's turn, as the slots are built from them:
* before folding, so a folded turn still has its reply to read. */
export type NativeChatRailTurnRows = {
messages: readonly NativeChatMessage[]
turnKeys: readonly (string | undefined)[]
}
/** What the agent answered a rail item's turn with, read when its preview opens:
* carried on every item, a streaming reply would rebuild the rail on each frame.
* Loaded rows are authoritative for the turns they hold, including the tail of a
* turn whose prompt is no longer loaded; the host's reply covers the rest. */
export function nativeChatRailReplyPreview(
{ messages, turnKeys }: NativeChatRailTurnRows,
items: readonly NativeChatRailItem[],
id: string
): string {
const row = messages.findIndex((message) => message.id === id)
const item = items.find((candidate) => candidate.id === id)
// An unloaded prompt is its turn's key unless the host names another: a steer's
// turn is the one it was sent into.
const turnKey = row === -1 ? (item?.turnKey ?? id) : turnKeys[row]
const loaded =
turnKey === undefined
? undefined
: nativeChatTurnReplyPreviews(messages, turnKeys, turnKey).get(turnKey)
return loaded ?? item?.reply ?? ''
}
export function buildNativeChatRailItems(
@@ -36,9 +86,12 @@ export function buildNativeChatRailItems(
): readonly NativeChatRailItem[] {
const items: NativeChatRailItem[] = []
for (const [slotIndex, slot] of slots.entries()) {
// A send a Stop took back is no tick, as the host's outline of older history leaves it out.
// A send a Stop took back is no tick, as the host's outline of older history leaves it
// out. Nor is a subagent's prompt, drawn while its section is open: the rail maps
// the conversation, and must not change with what the reader has expanded.
if (
slot.kind !== 'message' ||
slot.depth !== 0 ||
slot.message.role !== 'user' ||
slot.message.stoppedBeforeStart === true
) {
@@ -81,38 +134,47 @@ export function mergeNativeChatRailOutline(
}
/** Evenly spaced ticks across the whole thread, always including both ends and
* the active one. Keeping the ends fixed is what makes the rail read as a map
* of the conversation rather than a window onto part of it. */
* the kept ones: the tick the reader is on, and any they are previewing or have
* focused. Keeping the ends fixed is what makes the rail read as a map of the
* conversation rather than a window onto part of it. */
export function selectNativeChatRailTicks({
items,
activeId
keepIds = [],
maxTicks = NATIVE_CHAT_RAIL_ROOMY_TICKS
}: {
items: readonly NativeChatRailItem[]
activeId: string | null
keepIds?: readonly (string | null)[]
maxTicks?: number
}): readonly NativeChatRailItem[] {
if (items.length <= NATIVE_CHAT_RAIL_MAX_TICKS) {
if (items.length <= maxTicks) {
return items
}
const maxIndex = items.length - 1
const sampled = new Set<number>()
for (let slot = 0; slot < NATIVE_CHAT_RAIL_MAX_TICKS; slot += 1) {
sampled.add(Math.round((slot * maxIndex) / (NATIVE_CHAT_RAIL_MAX_TICKS - 1)))
for (let slot = 0; slot < maxTicks; slot += 1) {
sampled.add(Math.round((slot * maxIndex) / (maxTicks - 1)))
}
const activeIndex = activeId === null ? -1 : items.findIndex((item) => item.id === activeId)
if (activeIndex >= 0 && !sampled.has(activeIndex)) {
sampled.add(activeIndex)
// Drop the neighbour nearest the active tick, never an end: losing an end
const kept = new Set<number>([0, maxIndex])
for (const id of keepIds) {
const index = id === null ? -1 : items.findIndex((item) => item.id === id)
if (index >= 0) {
kept.add(index)
}
}
for (const keep of kept) {
if (sampled.has(keep)) {
continue
}
sampled.add(keep)
// Drop the nearest neighbour that is not itself kept, never an end: losing one
// would make the rail claim the thread starts or stops somewhere it doesn't.
let evict: number | null = null
let evictDistance = Number.POSITIVE_INFINITY
for (const index of sampled) {
if (index === activeIndex || index === 0 || index === maxIndex) {
continue
}
const distance = Math.abs(index - activeIndex)
if (distance < evictDistance) {
const distance = Math.abs(index - keep)
if (!kept.has(index) && distance < evictDistance) {
evict = index
evictDistance = distance
}
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS,
nativeChatTurnReplyPreviews,
projectAgentSessionConversationOutline,
truncateOutlinePreview
} from '../../../../shared/agent-session-conversation-outline'
@@ -11,9 +13,15 @@ import type {
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildNativeChatRailItems } from './native-chat-message-rail-items'
import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping'
import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership'
import {
buildNativeChatRailItems,
nativeChatRailReplyPreview
} from './native-chat-message-rail-items'
import { createNativeChatMessageListProjection } from './native-chat-message-list-projection'
import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt'
import { nativeChatSubagentSections } from './native-chat-subagent-sections'
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows'
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
@@ -66,31 +74,53 @@ const JOURNAL: AgentJournalRenderItem[] = [
{ ...user(12, [{ type: 'text', text: 'Observed earlier' }]), observedAt: 1_009.5 }
]
/** The renderer's own path from journal items to rail items, as the list runs it. */
function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) {
const projected = createNativeChatMessageListProjection()(
/** The renderer's own path from journal items to slots, as the list runs it.
* `openSections`: the subagent sections the reader has expanded. */
function loadedSlots(
items: AgentJournalRenderItem[],
submissions: AgentJournalSubmission[],
openSections: readonly string[] = []
) {
const projection = createNativeChatMessageListProjection()(
projectStructuredAgentSessionMessages(items, [], submissions)
).conversation
const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected))
let turn: string | undefined
const turnKeys = messages.map((message) => {
if (message.role === 'user') {
turn = message.id
}
return turn
})
)
const messages = omitNativeChatThreadGoalRows(
projectNativeChatTaskListFrames(projection.conversation)
)
const membership = nativeChatTurnMembership(messages, { items, submissions })
const rows = {
messages: nativeChatRowsInDrawOrder(messages, membership.drawOrder),
turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder)
}
const slots = buildNativeChatTranscriptSlots({
messages,
turnKeys,
liveTurnKey: turn,
...rows,
liveTurnKey: membership.liveTurnKey,
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
expandedTurnKeys: new Set<string>(),
isWorking: false,
lifecycleWorking: false
lifecycleWorking: false,
subagentSections: nativeChatSubagentSections(messages, projection.subagentRows),
subagentChoices: {
sections: new Map(openSections.map((agentId) => [agentId, true])),
rosters: new Map()
}
})
return buildNativeChatRailItems(slots)
return { rows, slots }
}
function loadedRailItems(
items: AgentJournalRenderItem[],
submissions: AgentJournalSubmission[],
openSections?: readonly string[]
) {
const { rows, slots } = loadedSlots(items, submissions, openSections)
const railItems = buildNativeChatRailItems(slots)
return railItems.map((item) => ({
...item,
reply: nativeChatRailReplyPreview(rows, railItems, item.id)
}))
}
describe('conversation outline parity with the loaded rail', () => {
@@ -108,9 +138,10 @@ describe('conversation outline parity with the loaded rail', () => {
outline.map((entry) => ({
id: entry.itemId,
text: entry.preview,
hasImages: entry.imageCount > 0
hasImages: entry.imageCount > 0,
reply: entry.reply ?? ''
}))
).toEqual(loaded.map(({ id, text, hasImages }) => ({ id, text, hasImages })))
).toEqual(loaded.map(({ id, text, hasImages, reply }) => ({ id, text, hasImages, reply })))
// Anti-vacuous: the folded tool result, the refused send, the harness turn and the empty
// prompt were all dropped, and the recovered row sits where it was journalled.
expect(outline.map((entry) => entry.itemId)).toEqual([
@@ -151,18 +182,153 @@ describe('conversation outline parity with the loaded rail', () => {
).toContain(agentJournalSubmissionKey('client-stopped'))
})
it('carries each entry its creation sequence and image count', () => {
// An imported tool result rides a user row mid-turn; it must not end the turn's reply.
it('reads a reply past a tool result folded into the turn, loaded or outlined', () => {
const journal = [
user(1, [{ type: 'text', text: 'Fix it' }]),
row(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] }),
row(3, { kind: 'tool-call', name: 'Read', state: 'completed', input: { file_path: 'a.ts' } }),
user(4, [{ type: 'tool-result', output: 'file body' }]),
row(5, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Fixed.' }] })
]
expect(projectAgentSessionConversationOutline(journal, []).map((entry) => entry.reply)).toEqual(
['Fixed.']
)
expect(loadedRailItems(journal, []).map((item) => item.reply)).toEqual(['Fixed.'])
})
// A subagent's rows are its own, not the conversation's: its prompt is no tick and
// its prose is no reply, whether or not its section is open.
it('never reads a subagent into the rail, as a tick or as a reply', () => {
const journal = [
user(1, [{ type: 'text', text: 'Fix it' }]),
row(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Fixed.' }] }),
{ ...user(3, [{ type: 'text', text: 'Child task' }]), agentId: 'sub-1' },
{
...row(4, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Child done.' }]
}),
agentId: 'sub-1'
}
]
const outline = projectAgentSessionConversationOutline(journal, [])
expect(outline.map(({ itemId, reply }) => ({ id: itemId, reply }))).toEqual([
{ id: 'item-1', reply: 'Fixed.' }
])
// Anti-vacuous: with its section open, the child's prompt is a drawn row.
const open = loadedSlots(journal, [], ['sub-1']).slots
expect(open.some((slot) => slot.kind === 'message' && slot.message.id === 'item-3')).toBe(true)
for (const sections of [[], ['sub-1']]) {
expect(
loadedRailItems(journal, [], sections).map(({ id, reply }) => ({ id, reply }))
).toEqual([{ id: 'item-1', reply: 'Fixed.' }])
}
})
// A steer joins the running turn, so the host names that turn for it: a client
// that has not loaded the steer cannot otherwise tell whose reply it shares.
it("names the turn of a steer, and gives it that turn's reply", () => {
const inTurn = { turnScope: { kind: 'turn' as const, turnItemId: 'turn-1' } }
const journal: AgentJournalRenderItem[] = [
{ ...user(1, [{ type: 'text', text: 'Fix it' }]), ...inTurn },
row(
2,
{ kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: 'item-1', startedAt: 0 },
'turn-1'
),
{
...row(3, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Looking.' }]
}),
...inTurn
},
{ ...user(4, [{ type: 'text', text: 'Also the tests' }]), ...inTurn },
{
...row(5, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Done.' }]
}),
...inTurn
}
]
expect(
projectAgentSessionConversationOutline(journal, []).map(({ itemId, turnKey, reply }) => ({
itemId,
turnKey,
reply
}))
).toEqual([
{ itemId: 'item-1', turnKey: undefined, reply: 'Done.' },
{ itemId: 'item-4', turnKey: 'item-1', reply: 'Done.' }
])
})
it('carries each entry its creation sequence, image count and the reply to it', () => {
const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED])
expect(outline).toEqual([
{ itemId: 'item-1', sequence: 1, preview: 'Fix the parser', imageCount: 0 },
{ itemId: 'item-1', sequence: 1, preview: 'Fix the parser', imageCount: 0, reply: 'On it.' },
{ itemId: 'item-5', sequence: 5, preview: '', imageCount: 1 },
{ itemId: 'item-9', sequence: 9, preview: 'Compare these', imageCount: 2 },
{
itemId: 'item-9',
sequence: 9,
preview: 'Compare these',
imageCount: 2,
reply: 'Done.'
},
{ itemId: 'item-11', sequence: 11, preview: 'Thanks', imageCount: 0 },
{ itemId: 'item-12', sequence: 12, preview: 'Observed earlier', imageCount: 0 }
])
})
})
describe('reply preview', () => {
const says = (text: string): Pick<NativeChatMessage, 'role' | 'blocks'> => ({
role: 'assistant',
blocks: [{ type: 'text', text }]
})
const asks: Pick<NativeChatMessage, 'role' | 'blocks'> = {
role: 'user',
blocks: [{ type: 'text', text: 'prompt' }]
}
it("is the turn's last assistant prose, as plain text, whatever sits between", () => {
const fixed =
'## Fixed\n\n```ts\nconst hidden = 1\n```\n\n- The **parser** now reads `a.ts`.\n---'
expect(
nativeChatTurnReplyPreviews(
// A steer and a prompt queued for the next turn both land mid-turn.
[asks, says('Let me look.'), asks, asks, says(fixed), says('A later turn.')],
['t1', 't1', 't1', 't2', 't1', 't2']
)
).toEqual(
new Map([
['t1', 'Fixed The parser now reads a.ts.'],
['t2', 'A later turn.']
])
)
})
it('keeps earlier prose when the last message is only code, and skips a turn with none', () => {
expect(
nativeChatTurnReplyPreviews(
[says('Here it is:'), says('```\ncode\n```'), asks],
['t1', 't1', 't2']
)
).toEqual(new Map([['t1', 'Here it is:']]))
})
it('is cut to the preview cap', () => {
expect(nativeChatTurnReplyPreviews([says('word '.repeat(200))], ['t1']).get('t1')).toHaveLength(
AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS - 1
)
})
})
describe('outline preview truncation', () => {
it('cuts to the cap without splitting a surrogate pair', () => {
expect(truncateOutlinePreview('short', 10)).toBe('short')
@@ -67,6 +67,8 @@ function gestureLeavesEnd(gesture: ReaderGesture, transcript: HTMLElement): bool
type ReaderScrollCallbacks = {
onReaderScroll: () => void
/** The same gesture, for whatever was still moving the transcript to yield to it. */
onTakeScroll?: () => void
onLeaveEnd: () => void
}
@@ -135,8 +137,12 @@ export function nativeChatReaderScrollInputHandlers({
/** The transcript scroller's input props, and the wheel the rail overlaying it forwards. */
export function useNativeChatReaderScrollInput(
scrollRef: React.RefObject<HTMLElement | null>,
{ onReaderScroll, onLeaveEnd }: ReaderScrollCallbacks
{ onReaderScroll: onScrollInput, onTakeScroll, onLeaveEnd }: ReaderScrollCallbacks
): { scrollerProps: NativeChatReaderScrollInputHandlers; railWheel: (deltaY: number) => void } {
const onReaderScroll = useCallback(() => {
onScrollInput()
onTakeScroll?.()
}, [onScrollInput, onTakeScroll])
const scrollerProps = useMemo(
() => nativeChatReaderScrollInputHandlers({ onReaderScroll, onLeaveEnd }),
[onLeaveEnd, onReaderScroll]
@@ -123,6 +123,7 @@ function useReviewTranscript() {
showsTailRow: true,
isVisible: true,
alignToViewportTop: vi.fn(),
isAlignPending: () => false,
scrollToEnd,
restoreScrollOffset: vi.fn(),
consumeProgrammaticScroll: () => false,
@@ -69,7 +69,9 @@ export function selectStructuredRailOutline(
.map((entry) => ({
id: entry.itemId,
text: entry.preview,
hasImages: entry.imageCount > 0
hasImages: entry.imageCount > 0,
reply: entry.reply,
turnKey: entry.turnKey
}))
}
views.set(outline, { edge: oldestLoadedSequence, count, view })
@@ -41,6 +41,9 @@ function slotsOf(messages: NativeChatMessage[]) {
})
}
/** No test but the preview's reads the rows the slots came from. */
const NO_ROWS = { messages: [], turnKeys: [] }
const CONVERSATION = [
message('u1', 'user'),
message('a1', 'assistant'),
@@ -56,7 +59,8 @@ describe('message rail hook', () => {
)
const scrollRef = { current: document.createElement('div') }
const { result, rerender, unmount } = renderHook(
({ slots }) => useNativeChatMessageRail({ scrollRef, slots, virtualItems: [] }),
({ slots }) =>
useNativeChatMessageRail({ scrollRef, slots, turnRows: NO_ROWS, virtualItems: [] }),
{ initialProps: { slots: slotsOf(conversation) } }
)
const initial = result.current
@@ -75,6 +79,36 @@ describe('message rail hook', () => {
derive.mockRestore()
})
it('keeps the previewed reply current while its turn streams', () => {
const scrollRef = { current: document.createElement('div') }
const streamed = (body: string) => {
const messages = [
...CONVERSATION,
{ ...message('a3', 'assistant'), blocks: [{ type: 'text' as const, text: body }] }
]
return {
slots: slotsOf(messages),
turnRows: { messages, turnKeys: ['u1', 'u1', 'u2', 'u2', 'u3', 'u3'] }
}
}
const { result, rerender, unmount } = renderHook(
({ slots, turnRows }) =>
useNativeChatMessageRail({ scrollRef, slots, turnRows, virtualItems: [] }),
{ initialProps: streamed('Thinking') }
)
expect(result.current.previewReply).toBe('')
act(() => result.current.onPreview('u3'))
expect(result.current.previewReply).toBe('Thinking')
rerender(streamed('Thinking it through'))
expect(result.current.previewReply).toBe('Thinking it through')
// Closed, the rail's state stops following the stream.
act(() => result.current.onPreview(null))
const closed = result.current
rerender(streamed('Thinking it through, done'))
expect(result.current).toBe(closed)
unmount()
})
it('removes its scroll listener and pending idle read on unmount', () => {
vi.useFakeTimers()
const element = document.createElement('div')
@@ -83,6 +117,7 @@ describe('message rail hook', () => {
useNativeChatMessageRail({
scrollRef: { current: element },
slots: slotsOf(CONVERSATION),
turnRows: NO_ROWS,
virtualItems: []
})
)
@@ -103,7 +138,8 @@ describe('message rail hook', () => {
const addListener = vi.spyOn(element, 'addEventListener')
const { rerender } = renderHook(
({ slots }) => useNativeChatMessageRail({ scrollRef, slots, virtualItems: [] }),
({ slots }) =>
useNativeChatMessageRail({ scrollRef, slots, turnRows: NO_ROWS, virtualItems: [] }),
{ initialProps: { slots: slotsOf(CONVERSATION) } }
)
// Same prompts, new array identity — exactly what a re-render produces.
@@ -119,7 +155,12 @@ describe('message rail hook', () => {
const scrollRef = { current: element }
const { result } = renderHook(() =>
useNativeChatMessageRail({ scrollRef, slots: slotsOf(CONVERSATION), virtualItems: [] })
useNativeChatMessageRail({
scrollRef,
slots: slotsOf(CONVERSATION),
turnRows: NO_ROWS,
virtualItems: []
})
)
expect(result.current.items.map((item) => item.id)).toEqual(['u1', 'u2', 'u3'])
expect(result.current.visible).toBe(true)
@@ -128,13 +169,14 @@ describe('message rail hook', () => {
useNativeChatMessageRail({
scrollRef,
slots: slotsOf([message('u1', 'user'), message('a1', 'assistant')]),
turnRows: NO_ROWS,
virtualItems: []
})
)
expect(single.current.visible).toBe(true)
const { result: empty } = renderHook(() =>
useNativeChatMessageRail({ scrollRef, slots: [], virtualItems: [] })
useNativeChatMessageRail({ scrollRef, slots: [], turnRows: NO_ROWS, virtualItems: [] })
)
expect(empty.current.visible).toBe(false)
})
@@ -148,7 +190,13 @@ describe('message rail hook', () => {
}))
const loaded = [message('u1', 'user'), message('a1', 'assistant')]
const { result } = renderHook(() =>
useNativeChatMessageRail({ scrollRef, slots: slotsOf(loaded), virtualItems: [], outline })
useNativeChatMessageRail({
scrollRef,
slots: slotsOf(loaded),
turnRows: NO_ROWS,
virtualItems: [],
outline
})
)
expect(result.current.visible).toBe(true)
expect(result.current.items.map((item) => item.id)).toEqual([
@@ -10,9 +10,13 @@ import { findActiveNativeChatRailItem } from './native-chat-active-rail-item'
import {
buildNativeChatRailItems,
mergeNativeChatRailOutline,
NATIVE_CHAT_RAIL_ROOMY_TICKS,
nativeChatRailReplyPreview,
nativeChatRailTickCapacity,
selectNativeChatRailTicks,
type NativeChatRailItem,
type NativeChatRailOutlineEntry
type NativeChatRailOutlineEntry,
type NativeChatRailTurnRows
} from './native-chat-message-rail-items'
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window'
@@ -20,31 +24,49 @@ import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-wi
/** Quiet period that counts as "stopped scrolling". */
export const NATIVE_CHAT_RAIL_IDLE_MS = 120
/** Narrower than this the panel would cover the message it previews, so the whole
* rail stands down rather than half-working in a split pane. */
/** Narrower than this the preview card would cover the transcript it sits beside,
* so the whole rail stands down rather than half-working in a split pane. */
export const NATIVE_CHAT_RAIL_MIN_WIDTH_PX = 512
export type NativeChatMessageRailState = {
ticks: readonly NativeChatRailItem[]
items: readonly NativeChatRailItem[]
activeId: string | null
/** Light a tick now, ahead of the scroll that is taking the reader to it. */
onActivate: (id: string) => void
visible: boolean
/** The item whose preview is open, if any. */
previewId: string | null
/** The item whose tick holds keyboard focus, if any. */
focusId: string | null
/** The rail names the item it focused, or is about to: an item named here
* always has a tick, so arrow keys can walk every message, sampled or not. */
onFocusItem: (id: string | null) => void
/** What the agent answered the previewed item with; empty when nothing is
* previewed or it has said nothing. Follows a reply that is still streaming. */
previewReply: string
/** The rail names the item its preview shows, or null once it closes. */
onPreview: (id: string | null) => void
}
export function useNativeChatMessageRail({
scrollRef,
slots,
turnRows,
virtualItems,
outline = null
}: {
scrollRef: React.RefObject<HTMLDivElement | null>
slots: readonly NativeChatTranscriptSlot[]
/** The rows the slots were built from, for the previewed turn's reply. */
turnRows: NativeChatRailTurnRows
virtualItems: NativeChatTranscriptWindow['virtualItems']
/** User messages older than the loaded window; null when none are known. */
outline?: readonly NativeChatRailOutlineEntry[] | null
}): NativeChatMessageRailState {
const [activeId, setActiveId] = useState<string | null>(null)
const [wideEnough, setWideEnough] = useState(true)
const [maxTicks, setMaxTicks] = useState(NATIVE_CHAT_RAIL_ROOMY_TICKS)
const previousItemsRef = useRef<readonly NativeChatRailItem[]>([])
const loadedItems = buildNativeChatRailItems(slots, previousItemsRef.current)
@@ -122,20 +144,34 @@ export function useNativeChatMessageRail({
}
const observer = new ResizeObserver(() => {
setWideEnough(element.clientWidth >= NATIVE_CHAT_RAIL_MIN_WIDTH_PX)
setMaxTicks(nativeChatRailTickCapacity(element.clientHeight))
})
observer.observe(element)
return () => observer.disconnect()
}, [scrollRef])
const ticks = useMemo(() => selectNativeChatRailTicks({ items, activeId }), [items, activeId])
const [previewId, setPreviewId] = useState<string | null>(null)
const [focusId, setFocusId] = useState<string | null>(null)
const ticks = useMemo(
() => selectNativeChatRailTicks({ items, keepIds: [activeId, focusId, previewId], maxTicks }),
[items, activeId, focusId, previewId, maxTicks]
)
const previewReply =
previewId === null ? '' : nativeChatRailReplyPreview(turnRows, items, previewId)
return useMemo(
() => ({
ticks,
items,
activeId,
visible: wideEnough && items.length > 0
onActivate: setActiveId,
visible: wideEnough && items.length > 0,
previewId,
previewReply,
onPreview: setPreviewId,
focusId,
onFocusItem: setFocusId
}),
[ticks, items, activeId, wideEnough]
[ticks, items, activeId, wideEnough, previewId, previewReply, focusId]
)
}
@@ -0,0 +1,50 @@
// Carrying out a rail jump to a loaded message: scroll its row to the top, light
// its tick, release the request.
//
// Pinning the target mounts it in the same commit, so the row exists by the time
// layout runs. Routed through `scrollMessageToTop` rather than the virtualizer
// because that is what releases the bottom pin — without it the next streamed
// token snaps the reader straight back down.
//
// Serviced once per request, then released. `slots` takes a new identity on
// every render, so an effect that merely depended on it would re-scroll to this
// row forever; and a request left standing would keep its pin, which outranks
// the diff reveal that shares it.
import { useLayoutEffect, useRef } from 'react'
import {
nativeChatSlotIndexOf,
type NativeChatTranscriptSlot
} from './native-chat-transcript-slots'
export function useNativeChatRailJumpLanding({
railJump,
slots,
scrollRef,
scrollMessageToTop,
onActivate,
release
}: {
railJump: { messageId: string; requestId: number } | null
slots: readonly NativeChatTranscriptSlot[]
scrollRef: React.RefObject<HTMLDivElement | null>
scrollMessageToTop: (element: HTMLElement) => void
onActivate: (id: string) => void
release: (request: null) => void
}): void {
const servicedRef = useRef(0)
useLayoutEffect(() => {
if (railJump === null || servicedRef.current === railJump.requestId) {
return
}
servicedRef.current = railJump.requestId
const index = nativeChatSlotIndexOf(slots, railJump.messageId)
const row = scrollRef.current?.querySelector<HTMLElement>(`[data-index="${index}"]`)
if (row) {
scrollMessageToTop(row)
}
// Lit now: the scroll's own position report only arrives once it settles.
onActivate(railJump.messageId)
release(null)
}, [onActivate, railJump, release, scrollMessageToTop, scrollRef, slots])
}
@@ -29,6 +29,7 @@ function TranscriptHarness({
showsTailRow: false,
isVisible,
alignToViewportTop: vi.fn(),
isAlignPending: () => false,
scrollToEnd,
restoreScrollOffset,
consumeProgrammaticScroll: () => false,
@@ -59,6 +59,7 @@ export function useNativeChatTranscriptScroll({
showsTailRow,
isVisible,
alignToViewportTop,
isAlignPending,
scrollToEnd,
restoreScrollOffset,
consumeProgrammaticScroll,
@@ -72,6 +73,8 @@ export function useNativeChatTranscriptScroll({
showsTailRow: boolean
isVisible: boolean
alignToViewportTop: (element: HTMLElement) => void
/** Whether a jump to a row is still travelling there. */
isAlignPending: () => boolean
scrollToEnd: () => void
restoreScrollOffset: (offset: number) => void
consumeProgrammaticScroll: (event: Event) => boolean
@@ -117,13 +120,14 @@ export function useNativeChatTranscriptScroll({
following: followingRef.current,
programmatic: consumeProgrammaticScroll(event.nativeEvent),
geometry,
previousDistanceFromEnd: previousDistanceFromEndRef.current
previousDistanceFromEnd: previousDistanceFromEndRef.current,
settling: isAlignPending()
})
reconcileReaderScroll(false)
previousDistanceFromEndRef.current = distanceFromBottom(geometry)
syncScrollState()
},
[consumeProgrammaticScroll, reconcileReaderScroll, scrollRef, syncScrollState]
[consumeProgrammaticScroll, isAlignPending, reconcileReaderScroll, scrollRef, syncScrollState]
)
const scrollToEndWhenMeasurable = useCallback(() => {
@@ -14,10 +14,26 @@ type VirtualizerOptionsCapture = {
| null
}
type SizeChangeProbe = {
scrollOffset: number
scrollDirection: 'forward' | 'backward' | null
itemSizeCache: Map<string, number>
}
type SizeChangePolicyHolder = {
shouldAdjustScrollPositionOnItemSizeChange?: (
item: { index: number; key: string; end: number },
delta: number,
instance: SizeChangeProbe
) => boolean
}
const virtualizerMock = vi.hoisted(() => {
const scrollElement: { current: HTMLElement | null } = { current: null }
/** The instance the hook last received, where it sets its scroll-adjust policy. */
const instance: { current: SizeChangePolicyHolder | null } = { current: null }
return {
options: { current: null } as VirtualizerOptionsCapture,
instance,
getTotalSize: vi.fn(() => 0),
getVirtualItems: vi.fn(() => []),
measureElement: vi.fn(),
@@ -25,6 +41,11 @@ const virtualizerMock = vi.hoisted(() => {
resizeItem: vi.fn(),
scrollElement,
scrollToEnd: vi.fn(),
scrollToIndex: vi.fn(),
getOffsetForIndex: vi.fn<(index: number, align: string) => [number, string]>(() => [
0,
'start'
]),
scrollToOffset: vi.fn(),
takeSnapshot: vi.fn<() => VirtualItem[]>(() => [])
}
@@ -34,7 +55,12 @@ vi.mock('@tanstack/react-virtual', () => ({
elementScroll: vi.fn(),
useVirtualizer: (options: VirtualizerOptionsCapture['current']) => {
virtualizerMock.options.current = options
return { ...virtualizerMock, scrollElement: virtualizerMock.scrollElement.current }
const instance: SizeChangePolicyHolder & Record<string, unknown> = {
...virtualizerMock,
scrollElement: virtualizerMock.scrollElement.current
}
virtualizerMock.instance.current = instance
return instance
}
}))
@@ -222,6 +248,64 @@ describe('native chat transcript virtualizer contract', () => {
expect(virtualizerMock.scrollToOffset).not.toHaveBeenCalled()
})
// A jump owns the scroll until the view is at its row: layout under it can clamp
// the view elsewhere first, and no event in between marks its landing reliably.
it('holds a jump to a row pending until the view reaches it, or the reader takes the scroll', () => {
const scrollElement = document.createElement('div')
Object.defineProperties(scrollElement, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 1_000 }
})
const row = document.createElement('div')
row.dataset.index = '3'
scrollElement.append(row)
virtualizerMock.scrollElement.current = scrollElement
virtualizerMock.getOffsetForIndex.mockReturnValue([400, 'start'])
const { result } = renderHook(() =>
useNativeChatTranscriptWindow({
scrollRef: { current: scrollElement },
slots: [slot('message-0')],
isVisible: true,
revealIndex: -1
})
)
expect(result.current.isAlignPending()).toBe(false)
result.current.alignToViewportTop(row)
expect(virtualizerMock.scrollToIndex).toHaveBeenCalledWith(
3,
expect.objectContaining({ align: 'start' })
)
scrollElement.scrollTop = 900
expect(result.current.isAlignPending()).toBe(true)
scrollElement.scrollTop = 401
expect(result.current.isAlignPending()).toBe(false)
// A measured row growing above the view just after an upward scroll: left alone
// for a reader scrolling up, compensated under a jump resting where it landed.
const compensates = (heard = scrollElement.scrollTop): boolean | undefined =>
virtualizerMock.instance.current?.shouldAdjustScrollPositionOnItemSizeChange?.(
{ index: 1, key: 'row-1', end: 300 },
18,
{
scrollOffset: heard,
scrollDirection: 'backward',
itemSizeCache: new Map([['row-1', 100]])
}
)
expect(compensates()).toBe(true)
// Not from an offset the virtualizer has yet to hear of: an instant jump just
// wrote the view elsewhere, and a correction from the old offset would undo it.
expect(compensates(scrollElement.scrollTop + 500)).toBe(false)
result.current.alignToViewportTop(row)
scrollElement.scrollTop = 700
result.current.cancelAlign()
expect(result.current.isAlignPending()).toBe(false)
expect(compensates()).toBe(false)
expect(virtualizerMock.scrollToOffset).toHaveBeenLastCalledWith(700, { behavior: 'auto' })
})
// Rows drawn after the window (a message shown as not sent) still fill the container.
it('follows the bottom of the container when no row is windowed', () => {
const container = document.createElement('div')
@@ -9,16 +9,21 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { elementScroll, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate'
import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows'
import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots'
import {
nativeChatScrollOffsetWithin,
useNativeChatViewportAlign
} from './use-native-chat-viewport-align'
/** Rows kept mounted past each edge of the viewport. Chat rows are tall and
* arbitrarily expensive, so this buys smoothness by the row, not by the screen. */
export const NATIVE_CHAT_WINDOW_OVERSCAN = 6
const FALLBACK_ROW_PX = 48
/** Past this gap the DOM's offset is a write the virtualizer has yet to hear of. */
const UNSEEN_SCROLL_PX = 1
/** Retired keys are harmless to layout but otherwise accumulate for the pane's
* lifetime as a capped transcript advances. Compact them well before the stale
* entries become material compared with the live window. */
@@ -32,6 +37,11 @@ export type NativeChatTranscriptWindow = {
measureRow: (node: HTMLElement | null) => void
/** Scroll so this element's top meets the top of the viewport. */
alignToViewportTop: (element: HTMLElement) => void
/** Whether that scroll is still travelling: true until the view reaches its
* target, or another write or the reader takes the scroll from it. */
isAlignPending: () => boolean
/** A reader gesture that scrolls the transcript ends a scroll still travelling. */
cancelAlign: () => void
/** Pin to the transcript's end. Through the virtualizer for the same reason
* the reveal is: it owns the offset, and a write it does not recognise as its
* own is a reconcile it will fight. Its last-item `end` target is the
@@ -46,36 +56,6 @@ export type NativeChatTranscriptWindow = {
reconcileReaderScroll: (isTakingOver: boolean) => void
}
/** Distance from a container's scroll origin down to a descendant, in the
* container's own scroll pixels, or null when there is no chain to walk.
* Absolutely positioned windowed rows are placed with `top`, never a transform,
* so `offsetTop` stays true through the window as well. */
export function nativeChatScrollOffsetWithin(
element: HTMLElement,
container: HTMLElement
): number | null {
let top = 0
let node: HTMLElement | null = element
while (node !== null && node !== container) {
top += node.offsetTop
// A DOM without layout has no `offsetParent` at all; that ends the chain
// rather than walking into nothing, and the caller reads the null as
// "cannot place this yet".
const parent = node.offsetParent as HTMLElement | null | undefined
node = parent && typeof parent.offsetTop === 'number' ? parent : null
}
return node === container ? top : null
}
/** Fallback when layout provides no offset-parent chain. */
function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number {
return (
container.scrollTop +
element.getBoundingClientRect().top -
container.getBoundingClientRect().top
)
}
export function useNativeChatTranscriptWindow({
scrollRef,
slots,
@@ -94,7 +74,6 @@ export function useNativeChatTranscriptWindow({
const readerTakeoverFrameRef = useRef<number | null>(null)
const previousMeasurementKeysRef = useRef<ReadonlySet<string> | null>(null)
const retiredMeasurementCountRef = useRef(0)
const alignBehavior = usePrefersReducedMotion() ? 'auto' : 'smooth'
const pinned = useMemo(
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }),
[slots.length, revealIndex]
@@ -158,12 +137,6 @@ export function useNativeChatTranscriptWindow({
}
}
})
// Preserve rows above the reader, never compensate growth within the visible
// row — including its first measurement, which may follow an exact estimate.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.end <= (instance.scrollOffset ?? 0) &&
(instance.scrollDirection !== 'backward' || !instance.itemSizeCache.has(item.key))
const finishReaderTakeover = useCallback(() => {
if (readerTakeoverFrameRef.current !== null) {
window.cancelAnimationFrame(readerTakeoverFrameRef.current)
@@ -246,30 +219,34 @@ export function useNativeChatTranscriptWindow({
[readScrollMargin]
)
const alignToViewportTop = useCallback(
(element: HTMLElement) => {
const container = scrollRef.current
if (!container) {
return
}
const top =
nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container)
finishReaderTakeover()
// Through the virtualizer so a scroll it is still reconciling — the jump
// that mounted this row in the first place — is replaced rather than raced.
if (virtualizer.scrollElement) {
virtualizer.scrollToOffset(top, { align: 'start', behavior: alignBehavior })
} else {
const max = Math.max(0, container.scrollHeight - container.clientHeight)
const landing = Math.max(0, Math.min(top, max))
if (container.scrollTop !== landing) {
programmaticScrollMarks.mark(landing)
}
container.scrollTo({ top, behavior: alignBehavior })
}
},
[alignBehavior, finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer]
)
const { alignToViewportTop, isAlignPending, endAlign, isAlignHeld } = useNativeChatViewportAlign({
scrollRef,
virtualizer,
finishReaderTakeover,
programmaticScrollMarks
})
// Preserve rows above the reader, never compensate growth within the visible
// row — including its first measurement, which may follow an exact estimate.
// Nor the first measurement of a jump's target row: at the end its correction is
// clamped, and the virtualizer retries it after the jump starts, cancelling it.
// Nor while the virtualizer has not seen a scroll just written: it corrects from
// the offset it last heard, which would put the view back where the write left.
// A row re-measured during a backward scroll is left alone, so rows do not jump
// under a reader scrolling up; a jump that landed is not that reader, and a row
// growing above it would otherwise push its message down for good.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const offset = instance.scrollOffset ?? 0
const written = scrollRef.current?.scrollTop ?? offset
return (
Math.abs(written - offset) <= UNSEEN_SCROLL_PX &&
!(item.index === revealIndex && !instance.itemSizeCache.has(item.key)) &&
item.end <= offset &&
(instance.scrollDirection !== 'backward' ||
!instance.itemSizeCache.has(item.key) ||
isAlignHeld())
)
}
const scrollToEnd = useCallback(() => {
const container = scrollRef.current
@@ -277,6 +254,7 @@ export function useNativeChatTranscriptWindow({
return
}
finishReaderTakeover()
endAlign()
// With no windowed row it would resolve the end from its own rows' height, 0, though rows
// drawn after the window (a message shown as not sent) still fill the container.
if (virtualizer.scrollElement && slots.length > 0) {
@@ -291,6 +269,7 @@ export function useNativeChatTranscriptWindow({
programmaticScrollMarks.mark(container.scrollTop)
}
}, [
endAlign,
finishReaderTakeover,
isVisible,
programmaticScrollMarks,
@@ -306,6 +285,7 @@ export function useNativeChatTranscriptWindow({
return
}
finishReaderTakeover()
endAlign()
if (virtualizer.scrollElement) {
virtualizer.scrollToOffset(offset, { behavior: 'auto' })
return
@@ -316,7 +296,7 @@ export function useNativeChatTranscriptWindow({
programmaticScrollMarks.mark(container.scrollTop)
}
},
[finishReaderTakeover, isVisible, programmaticScrollMarks, scrollRef, virtualizer]
[endAlign, finishReaderTakeover, isVisible, programmaticScrollMarks, scrollRef, virtualizer]
)
const consumeProgrammaticScroll = useCallback(
@@ -344,6 +324,7 @@ export function useNativeChatTranscriptWindow({
) {
return
}
endAlign()
virtualizer.scrollToOffset(container.scrollTop, { behavior: 'auto' })
if (readerTakeoverFrameRef.current !== null) {
return
@@ -354,9 +335,17 @@ export function useNativeChatTranscriptWindow({
readerTakeoverFrameRef.current = null
})
},
[scrollRef, virtualizer]
[endAlign, scrollRef, virtualizer]
)
// The virtualizer still holds the jump's row as its target, and would write it
// again as rows measure: rebase it onto where the reader is.
const cancelAlign = useCallback(() => {
if (isAlignHeld()) {
reconcileReaderScroll(true)
}
}, [isAlignHeld, reconcileReaderScroll])
return {
virtualItems: virtualizer.getVirtualItems(),
totalSize: virtualizer.getTotalSize(),
@@ -364,6 +353,8 @@ export function useNativeChatTranscriptWindow({
sizerRef,
measureRow: virtualizer.measureElement,
alignToViewportTop,
isAlignPending,
cancelAlign,
scrollToEnd,
restoreScrollOffset,
consumeProgrammaticScroll,
@@ -0,0 +1,130 @@
// Scrolling a transcript element to the top of the viewport, and knowing while
// that scroll is still travelling: rows it passes resize under it, so nothing
// short of the view reaching its target says the jump is over.
import { useCallback, useRef } from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'
/** How close to its target a jump counts as arrived: sub-pixel and zoom rounding. */
const ALIGN_ARRIVED_PX = 2
/** Distance from a container's scroll origin down to a descendant, in the
* container's own scroll pixels, or null when there is no chain to walk.
* Absolutely positioned windowed rows are placed with `top`, never a transform,
* so `offsetTop` stays true through the window as well. */
export function nativeChatScrollOffsetWithin(
element: HTMLElement,
container: HTMLElement
): number | null {
let top = 0
let node: HTMLElement | null = element
while (node !== null && node !== container) {
top += node.offsetTop
// A DOM without layout has no `offsetParent` at all; that ends the chain
// rather than walking into nothing, and the caller reads the null as
// "cannot place this yet".
const parent = node.offsetParent as HTMLElement | null | undefined
node = parent && typeof parent.offsetTop === 'number' ? parent : null
}
return node === container ? top : null
}
/** Fallback when layout provides no offset-parent chain. */
function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number {
return (
container.scrollTop +
element.getBoundingClientRect().top -
container.getBoundingClientRect().top
)
}
export function useNativeChatViewportAlign({
scrollRef,
virtualizer,
finishReaderTakeover,
programmaticScrollMarks
}: {
scrollRef: React.RefObject<HTMLDivElement | null>
virtualizer: Virtualizer<HTMLDivElement, Element>
finishReaderTakeover: () => void
programmaticScrollMarks: ProgrammaticScrollMarks
}): {
alignToViewportTop: (element: HTMLElement) => void
isAlignPending: () => boolean
/** Another write, or the reader, took the scroll. */
endAlign: () => void
/** Whether the view is still the jump's: travelling, or resting where it landed. */
isAlignHeld: () => boolean
} {
const alignBehavior = usePrefersReducedMotion() ? 'auto' : 'smooth'
/** The row or offset `alignToViewportTop` scrolled to, until something else takes the scroll. */
const alignRef = useRef<(({ index: number } | { offset: number }) & { arrived: boolean }) | null>(
null
)
const alignToViewportTop = useCallback(
(element: HTMLElement) => {
const container = scrollRef.current
if (!container) {
return
}
finishReaderTakeover()
const index = Number.parseInt(element.dataset.index ?? '', 10)
// Through the virtualizer so a scroll it is still reconciling — the jump
// that mounted this row in the first place — is replaced rather than raced.
if (virtualizer.scrollElement && !Number.isNaN(index)) {
// By index for a row: rows measured on the way move the target, and the
// virtualizer re-aims at an index where a fixed offset would land beside it.
alignRef.current = { index, arrived: false }
virtualizer.scrollToIndex(index, { align: 'start', behavior: alignBehavior })
return
}
const top =
nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container)
alignRef.current = { offset: top, arrived: false }
if (virtualizer.scrollElement) {
virtualizer.scrollToOffset(top, { align: 'start', behavior: alignBehavior })
return
}
const max = Math.max(0, container.scrollHeight - container.clientHeight)
const landing = Math.max(0, Math.min(top, max))
if (container.scrollTop !== landing) {
programmaticScrollMarks.mark(landing)
}
container.scrollTo({ top, behavior: alignBehavior })
},
[alignBehavior, finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer]
)
// Read from where the view is, not from a landing event: a stale mark or a
// scroll end left by an earlier write can precede the jump's first frame.
const isAlignPending = useCallback(() => {
const pending = alignRef.current
const container = scrollRef.current
if (pending === null || pending.arrived || !container) {
return false
}
const target =
'index' in pending
? virtualizer.getOffsetForIndex(pending.index, 'start')?.[0]
: pending.offset
const max = Math.max(0, container.scrollHeight - container.clientHeight)
if (
target === undefined ||
Math.abs(container.scrollTop - Math.min(target, max)) <= ALIGN_ARRIVED_PX
) {
pending.arrived = true
return false
}
return true
}, [scrollRef, virtualizer])
const endAlign = useCallback(() => {
alignRef.current = null
}, [])
const isAlignHeld = useCallback(() => alignRef.current !== null, [])
return { alignToViewportTop, isAlignPending, endAlign, isAlignHeld }
}
@@ -316,6 +316,7 @@ function renderRevealingController(sessionId = 'session-1', fence: number | null
showsTailRow: false,
isVisible,
alignToViewportTop: vi.fn(),
isAlignPending: () => false,
scrollToEnd,
restoreScrollOffset,
consumeProgrammaticScroll: () => false,
@@ -11,12 +11,14 @@ import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from './agent-session-journal-types'
import type { NativeChatBlock } from './native-chat-types'
import type { NativeChatBlock, NativeChatMessage } from './native-chat-types'
import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content'
import { iterateProcessOutputLines } from './process-output-field-scanner'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection'
import { nativeChatTurnMembership } from './native-chat-turn-membership'
/** Previews are cut on the host: the rail clamps to two lines, so a whole prompt
* would cross the wire only to be hidden. */
@@ -31,6 +33,12 @@ export type AgentSessionConversationOutlineEntry = {
* message is images only, or when the host dropped previews to fit the reply. */
preview: string
imageCount: number
/** The message that opened this one's turn, when this one did not: a steer sent
* into a running turn. Absent from a host that predates the field. */
turnKey?: string
/** What the agent answered, as preview prose. Absent when it said nothing, from a
* host that predates the field, or when the host dropped replies to fit. */
reply?: string
}
export type AgentSessionConversationOutline = {
@@ -65,6 +73,79 @@ export function nativeChatUserMessagePreview(
return preview
}
const replies = new WeakMap<readonly NativeChatBlock[], string>()
/** An assistant message as preview prose: fenced code, rules and markdown markers
* dropped, cut to the preview cap. */
function assistantReplyProse(blocks: readonly NativeChatBlock[]): string {
const cached = replies.get(blocks)
if (cached !== undefined) {
return cached
}
const lines: string[] = []
let length = 0
let fenced = false
// Lazily, stopping at the cap: a reply can be many thousands of lines.
for (const raw of iterateProcessOutputLines(deriveNativeChatRowContent(blocks).markdown)) {
const line = raw.trim()
if (line.startsWith('```')) {
fenced = !fenced
continue
}
if (fenced) {
continue
}
const plain = line
.replace(/^(?:[#>]+|[-*+]|\d+[.)])\s+/, '')
.replace(/\*\*|`/g, '')
.replace(/\s+/g, ' ')
.trim()
if (!/[\p{L}\p{N}]/u.test(plain)) {
continue
}
lines.push(plain)
length += plain.length + 1
if (length > AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS) {
break
}
}
const prose = truncateOutlinePreview(lines.join(' '), AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS)
replies.set(blocks, prose)
return prose
}
/** What the agent answered each turn with, by turn key: the prose of the turn's
* last assistant row that has any. Turns are the transcript's own (`turnKeys` from
* `nativeChatTurnMembership`), so a steer or a queued prompt cannot cut a reply
* short. `only` limits the read to one turn. */
export function nativeChatTurnReplyPreviews(
messages: readonly Pick<NativeChatMessage, 'role' | 'blocks'>[],
turnKeys: readonly (string | undefined)[],
only?: string
): Map<string, string> {
const replies = new Map<string, string>()
// Newest first, so a long agentic turn reads one message, not every aside before it.
for (let index = messages.length - 1; index >= 0; index -= 1) {
const turnKey = turnKeys[index]
if (
turnKey === undefined ||
(only !== undefined && turnKey !== only) ||
messages[index].role !== 'assistant' ||
replies.has(turnKey)
) {
continue
}
const prose = assistantReplyProse(messages[index].blocks)
if (prose.length > 0) {
replies.set(turnKey, prose)
if (only !== undefined) {
break
}
}
}
return replies
}
/** Cuts on a code-point boundary so a clipped emoji never leaves a lone surrogate. */
export function truncateOutlinePreview(text: string, maxChars: number): string {
if (text.length <= maxChars) {
@@ -100,7 +181,9 @@ export function projectAgentSessionConversationOutline(
// Unchanged on the wire: a desktop's rejected rows tick once their page is loaded.
projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false })
)
for (const message of transcript) {
const { turnKeys } = nativeChatTurnMembership(transcript, { items, submissions })
const replies = nativeChatTurnReplyPreviews(transcript, turnKeys)
for (const [index, message] of transcript.entries()) {
const sequence = sequences.get(message.id)
if (
sequence === undefined ||
@@ -111,11 +194,15 @@ export function projectAgentSessionConversationOutline(
continue
}
const preview = nativeChatUserMessagePreview(message.blocks)
const turnKey = turnKeys[index]
const reply = turnKey === undefined ? undefined : replies.get(turnKey)
entries.push({
itemId: message.id,
sequence,
preview: preview.text,
imageCount: preview.imageCount
imageCount: preview.imageCount,
...(turnKey === undefined || turnKey === message.id ? {} : { turnKey }),
...(reply === undefined ? {} : { reply })
})
}
return entries
+44 -32
View File
@@ -1,10 +1,10 @@
// Exercise the prompt picker and an off-window jump against a real transcript.
// Exercise the rail's per-tick preview and its jumps against a real transcript.
import { randomUUID } from 'node:crypto'
import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import type { Locator, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
@@ -65,10 +65,16 @@ function claudeTranscript(rowCount: number, sessionId: string): string {
const turn = Math.floor(index / 2)
const body = isUser
? `Question ${turn}: what does the rail do when I scroll a long reply?`
: Array.from(
{ length: 6 + (turn % 7) * 3 },
(_unused, line) => `Answer paragraph ${line + 1} for turn ${turn}.`
).join('\n\n')
: [
// Headings and code draw at heights the row estimate misses, so a jump
// crosses rows that resize under it.
`## Answer for turn ${turn}`,
`\`\`\`ts\n${Array.from({ length: 2 + (turn % 5) * 4 }, (_unused, line) => `const line${line} = ${turn}`).join('\n')}\n\`\`\``,
...Array.from(
{ length: 6 + (turn % 7) * 3 },
(_unused, line) => `Answer paragraph ${line + 1} for turn ${turn}.`
)
].join('\n\n')
return JSON.stringify({
sessionId,
uuid: `${sessionId}-${index}`,
@@ -116,43 +122,49 @@ test.describe('Native chat message rail', () => {
const rail = orcaPage.locator('[data-native-chat-rail]')
await expect(rail).toBeVisible({ timeout: 30_000 })
// Sampling cap: 30 user turns must not render 30 bars.
const tickCount = await rail.locator(':scope > span').count()
expect(tickCount).toBeGreaterThan(2)
expect(tickCount).toBeLessThanOrEqual(20)
// Every user turn has a tick while the rail fits: a tick is the only way to its message.
const ticks = rail.getByRole('button')
await expect(ticks).toHaveCount(TRANSCRIPT_ROWS / 2)
await orcaPage.screenshot({
path: path.join(SHOT_DIR, 'rail-01-app.png'),
animations: 'disabled'
})
await rail.hover()
const panel = orcaPage.getByRole('dialog', { name: 'Your messages' })
await expect(panel).toBeVisible({ timeout: 10_000 })
// The panel lists every user message, not the sampled ticks.
await expect(panel.getByRole('button').first()).toBeVisible()
// Hovering one tick previews that message and the reply to it, not the whole thread.
const tick = rail.getByRole('button', { name: 'Question 5:', exact: false })
await tick.hover()
const preview = orcaPage.locator('[data-slot="hover-card-content"]')
await expect(preview).toBeVisible({ timeout: 10_000 })
await expect(preview).toContainText('Question 5:')
await expect(preview).not.toContainText('Question 6:')
await expect(preview).toContainText('Answer for turn 5')
await expect(preview.locator('p')).toHaveCount(2)
await orcaPage.screenshot({
path: path.join(SHOT_DIR, 'rail-02-panel.png'),
path: path.join(SHOT_DIR, 'rail-02-preview.png'),
animations: 'disabled'
})
// Exact, not `> ticks`: a panel that listed only the sampled ticks would
// still satisfy a loose bound at 20 vs 20.
const panelCount = await panel.getByRole('button').count()
expect(panelCount).toBe(TRANSCRIPT_ROWS / 2)
// Activating the hover preview transfers focus into the prompt picker.
await rail.press('Enter')
await expect(panel.locator('button[data-current="true"]')).toBeFocused()
await panel.getByRole('button', { name: 'Question 5:', exact: false }).click()
await expect(panel).not.toBeVisible()
const target = transcriptWindow.locator('[data-index="10"]')
const scroller = orcaPage.locator('[data-native-chat-scroll]')
const targetOffset = async (): Promise<number> => {
const [row, viewport] = await Promise.all([target.boundingBox(), scroller.boundingBox()])
return row && viewport ? Math.abs(row.y - viewport.y) : Number.POSITIVE_INFINITY
const offsetOf = (row: Locator) => async (): Promise<number> => {
const [box, viewport] = await Promise.all([row.boundingBox(), scroller.boundingBox()])
return box && viewport ? Math.abs(box.y - viewport.y) : Number.POSITIVE_INFINITY
}
await expect.poll(targetOffset).toBeLessThan(4)
// One click lands the message at the top and lights its tick: from the end to a
// message near it, then across the thread, with rows resizing under each jump.
// Animated and instant jumps take different paths through the list.
for (const reducedMotion of ['reduce', 'no-preference'] as const) {
await orcaPage.emulateMedia({ reducedMotion })
for (const turn of [22, 12, 5]) {
const jumped = rail.getByRole('button', { name: `Question ${turn}:`, exact: false })
await jumped.click()
await expect
.poll(offsetOf(transcriptWindow.locator(`[data-index="${turn * 2}"]`)))
.toBeLessThan(4)
await expect(jumped).toHaveAttribute('aria-current', 'true')
}
}
const targetOffset = offsetOf(transcriptWindow.locator('[data-index="10"]'))
for (let revision = 0; revision < 3; revision += 1) {
const body = `Later streamed output ${revision}`
@@ -170,6 +182,6 @@ test.describe('Native chat message rail', () => {
await expect.poll(targetOffset).toBeLessThan(4)
}
console.log(`[rail] ticks=${tickCount} panelRows=${panelCount} shots=${SHOT_DIR}`)
console.log(`[rail] shots=${SHOT_DIR}`)
})
})