mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
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:
@@ -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
|
||||
|
||||
-6
@@ -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)
|
||||
|
||||
+6
-33
@@ -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(() => {
|
||||
|
||||
+85
-1
@@ -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 }
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user