mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
perf(native-chat): mount only the transcript rows near the viewport (#19869)
* refactor(native-chat): share one row-content derivation between row and list Windowing needs the list and the row to agree on which messages draw nothing: a row the list counts but the row declines to render would reserve estimated height for an empty slot. Extracts the block derivation out of NativeChatMessageRow into a module cached on the block array, so a streaming turn pays for it once per revision rather than once per consumer. * refactor(native-chat): keep an opened tool run open past its row's lifetime A tool run, tool line or diff card the reader opened is state they created, but it lives in the component's own `useState`. That is fine while every row is mounted forever. It stops being fine the moment rows can be unmounted: the run silently re-collapses behind the reader's back. Rows now read their disclosure from a transcript-level map when one is provided and fall back to their own state when they are rendered standalone. The controls that re-sync a run — the toolbar's expand-all, a turn's disclosure, a diff reveal — are folded into the key the choice is remembered under, so a control flip reads as "nothing recorded yet" and the new default stands without a mid-render write to a map an ancestor owns. `ToolLine` moves to its own file; the run was over the line cap with it. * perf(native-chat): mount only the transcript rows near the viewport A settled transcript mounts every row it has ever loaded, so the cost of opening a conversation grows with its length even though only a screenful is legible. Rows near the viewport are now the only ones in the document; the rest are reserved as estimated height and measured when they arrive. Four things had to change for that to be safe: - `zoom` moves from the transcript column onto the scroll container. Item measurements are in the zoomed content's pixels while `scrollTop` is not, so with the two split across the boundary the window's arithmetic was off by exactly the font scale — correct at the top of a transcript and blank deep inside it. The column's padding moves to a new inner element to keep the layout it had. This does mean the scrollbar itself zooms with the text. - The three siblings that made up a row — the message, the turn status, the turn's diff rollup — move into one wrapper that carries the spacing they used to take from the column. The spacing between rows is the window's `gap`, never the height estimate, which would otherwise be counted twice. - Messages that draw nothing no longer take a slot. Counted but undrawn, each one would reserve estimated height for a row that never appears. - Paging in older history is driven by scroll events alone. Every row that resolves its real height moves the content and re-fires the size observers, so the old "am I near the top?" test would have asked for another page once per measurement. It now also requires the view to have moved upwards and requires new items since the last request. Anchoring is the virtualizer's: `anchorTo: 'end'` re-resolves the row at the current offset across a count change, which replaces the hand-rolled prepend anchor, and `followOnAppend` keeps a reader at the bottom pinned there. The document-level bottom pin stays, because the typing indicator, the activity line and the column's end padding all live past the last row. Revealing a diff from a turn rollup can target a row that isn't mounted, so that row is pinned into the window and the card still reports its own position — a turn that touched four files lands on the one that was asked for. * fix(native-chat): let a pinned row reach the mounted window Two faults the windowing tests turned up, plus the handles they needed. The virtualizer memoizes its mounted index list on the range extractor's identity. Holding that identity stable — which is right for the measurement memo, and was the reason it was written that way — meant a row pinned after the fact was never picked up: revealing a diff in a row the window had left behind pointed at a row that stayed unmounted. The extractor now changes identity with the pinned set, which is not a dependency of the measurement memo, so nothing expensive is rebuilt. The offset a row sits at is read off the `offsetParent` chain, with a rect-based fallback for the case where there is none. Using that fallback for the window's own scroll margin was wrong in kind: with no layout to measure, it returns the scroll position itself, so the margin tracked the offset and the window sat at the top of the transcript wherever the reader scrolled. The margin now takes the offset chain or nothing; the fallback stays where it belongs, on the reveal. The scroll root and the window's spacer are named, so measurement can find the scroll root without depending on which utility class makes it scroll, and so a test can tell a window from a whole transcript. * test(native-chat): cover the windowed transcript, and prove the window engaged The integration harness stubs `offsetHeight` — on the scroll root and on every row — because that is what the virtualizer measures with, and a DOM without layout answers zero to all of it. Rows report the height their own estimate predicted, which keeps the reserved totals exact no matter which rows have been mounted long enough to be measured. Every case reads the window through one helper that refuses to pass when there is no window. Without that, raising the usability gate would send all of them down the whole-transcript path, where "fewer rows mounted than messages" is false but every other assertion still holds — and they would go on reporting green while covering nothing. Reserved height is asserted as an exact total rather than "greater than zero", which a degenerate empty window also satisfies, and the mounted range is asserted to bracket the offset rather than merely to be smaller than the transcript. Covered: the window mounts a subset and moves with the reader; the newest row and a reveal's target stay mounted from outside it; an opened tool run is still open when its row comes back; a message that draws nothing takes no slot; and the scroll root with no usable height still renders every row as a direct child of the transcript column. What the environment cannot show is stated where it matters rather than faked: its ResizeObserver never fires and a scroll assignment emits no event, so measurement settling, the bottom pin under a streaming turn, prepend anchoring and smooth scrolling are covered as pure decisions — height estimation, the pinned set, range extraction, and whether a position should page in older history — and left to a real renderer as behaviour. * docs(native-chat): say that one offset path does read rects * test(native-chat): pin the window against a row that grows in place Whole-message appends were covered; a row being replaced by a taller version of itself — what a streaming reply is — was not. The existing windowing harness gains two things it needs to see that: a scroll root with a real document (a height, a viewport, and a scrollTop that clamps), and a resize observer that delivers when a target's height actually changed, since happy-dom's never fires and nothing re-measures without it. Frame by frame, while one row grows from 24px to 6358px: the view stays 0px from the bottom, the row stays mounted, and the reserved total tracks the measurement rather than the estimate. A reader who scrolls up mid growth keeps the exact offset they chose for the rest of it. * test(native-chat): guard history prepend anchoring * test(native-chat): strengthen prepend anchor contract * fix(native-chat): preserve provider tool call identity * fix(native-chat): harden transcript windowing lifecycle * test(native-chat): install virtualizer viewport for turn timing * fix(native-chat): reject blank tool call identities --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
6c1580aeba
commit
1798786d4e
@@ -179,6 +179,7 @@ export function claudeToolBody(input: {
|
||||
kind: 'tool-call',
|
||||
name: input.tool.name,
|
||||
input: input.tool.input,
|
||||
callId: input.tool.id,
|
||||
state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running',
|
||||
...(input.result
|
||||
? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded }
|
||||
|
||||
@@ -528,6 +528,7 @@ describe('Claude structured journal translation', () => {
|
||||
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'Bash',
|
||||
callId: 'tool-1',
|
||||
state: 'completed',
|
||||
output: { head: 'a.ts\nb.ts', truncated: false }
|
||||
})
|
||||
@@ -546,6 +547,7 @@ describe('Claude structured journal translation', () => {
|
||||
expect(state.items.at(-1)?.body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'tool',
|
||||
callId: 'tool-1',
|
||||
input: null,
|
||||
output: { head: 'done again' }
|
||||
})
|
||||
@@ -606,6 +608,7 @@ describe('Claude structured journal translation', () => {
|
||||
])
|
||||
expect(state.items[0]?.body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
callId: 'tool-1',
|
||||
state: 'completed',
|
||||
output: { head: 'done' }
|
||||
})
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('codex item bodies', () => {
|
||||
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-2',
|
||||
input: { command: 'ls', cwd: '/tmp' },
|
||||
exitCode: 0,
|
||||
state: 'completed',
|
||||
@@ -229,6 +230,7 @@ describe('codex item bodies', () => {
|
||||
expect(body).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-read',
|
||||
// `name` is the target's basename, which `path` already carries and no
|
||||
// label ever reads, so it stays out of the bounded journal payload.
|
||||
input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' },
|
||||
@@ -257,6 +259,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'search',
|
||||
callId: 'item-search',
|
||||
input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' },
|
||||
state: 'running'
|
||||
})
|
||||
@@ -276,6 +279,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'search',
|
||||
callId: 'item-search-bare',
|
||||
input: { command: 'rg beta', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -296,6 +300,7 @@ describe('codex item bodies', () => {
|
||||
expect(body).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'list',
|
||||
callId: 'item-list',
|
||||
input: { command: 'ls', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -326,6 +331,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-mixed',
|
||||
input: { command: 'cat a.txt && ls src', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -349,6 +355,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-two-reads',
|
||||
input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -424,6 +431,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-read-null',
|
||||
input: { command: 'cat', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -451,6 +459,7 @@ describe('codex item bodies', () => {
|
||||
const shellRow = {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-fallback',
|
||||
input: { command: 'ls', cwd: '/tmp' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
@@ -624,6 +633,7 @@ describe('codex item bodies', () => {
|
||||
// Server-qualified, and the arguments stay top level so the row label can
|
||||
// read `query`/`command`/`file_path` out of them.
|
||||
name: 'weather/get_forecast',
|
||||
callId: 'mcp-1',
|
||||
mcpIdentity: { server: 'weather', tool: 'get_forecast' },
|
||||
input: { city: 'Oslo' },
|
||||
state: 'completed',
|
||||
@@ -672,6 +682,7 @@ describe('codex item bodies', () => {
|
||||
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 't',
|
||||
callId: 'm',
|
||||
input: null,
|
||||
state: 'running'
|
||||
})
|
||||
@@ -719,6 +730,7 @@ describe('codex item bodies', () => {
|
||||
expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
callId: 'w',
|
||||
input: null,
|
||||
state: 'running'
|
||||
})
|
||||
@@ -733,6 +745,7 @@ describe('codex item bodies', () => {
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
callId: 'w',
|
||||
input: {
|
||||
query: 'orca release notes',
|
||||
description: 'search',
|
||||
|
||||
@@ -93,6 +93,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: parsed?.name ?? 'shell',
|
||||
callId: item.id,
|
||||
// Raw command and cwd stay so the expanded view still shows what ran.
|
||||
input: boundToolInput(
|
||||
{ command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields },
|
||||
@@ -120,6 +121,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'apply_patch',
|
||||
callId: item.id,
|
||||
input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
state: commandState(item)
|
||||
},
|
||||
@@ -171,6 +173,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: mcpToolCallName(item),
|
||||
callId: item.id,
|
||||
...(server && tool ? { mcpIdentity: { server, tool } } : {}),
|
||||
input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
state: failure === null ? commandState(item) : 'failed',
|
||||
@@ -213,6 +216,7 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
callId: item.id,
|
||||
...(results.length > 0 ? { webSearchResults: results } : {}),
|
||||
input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
state: item.action === null || item.action === undefined ? 'running' : 'completed',
|
||||
|
||||
@@ -92,10 +92,13 @@ function codexResponseItem(
|
||||
payload.type === 'custom_tool_call'
|
||||
) {
|
||||
const name = extractString(payload.name) ?? 'tool'
|
||||
const callId = extractString(payload.call_id)
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
|
||||
blocks: [
|
||||
{ type: 'tool-call', name, input: codexCallInput(payload), ...(callId ? { callId } : {}) }
|
||||
],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('Codex transcript history modes', () => {
|
||||
expect(call).toMatchObject({
|
||||
id: 'call-1',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd' }]
|
||||
blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd', callId: 'durable-call-1' }]
|
||||
})
|
||||
expect(output).toMatchObject({
|
||||
id: 'fallback-output',
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('readNativeChatTranscript (claude)', () => {
|
||||
timestamp: '2026-06-01T10:05:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]
|
||||
content: [{ type: 'tool_use', id: 'tool-call-1', name: 'Bash', input: { command: 'ls' } }]
|
||||
}
|
||||
})
|
||||
records.push({
|
||||
@@ -98,7 +98,8 @@ describe('readNativeChatTranscript (claude)', () => {
|
||||
expect(toolCall?.blocks[0]).toEqual({
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' }
|
||||
input: { command: 'ls' },
|
||||
callId: 'tool-call-1'
|
||||
})
|
||||
|
||||
const toolResult = result.messages.at(-1)
|
||||
|
||||
@@ -81,7 +81,8 @@ function claudeContentBlock(record: Record<string, unknown>): NativeChatBlock |
|
||||
}
|
||||
case 'tool_use': {
|
||||
const name = extractString(record.name) ?? 'tool'
|
||||
return { type: 'tool-call', name, input: record.input }
|
||||
const callId = extractString(record.id)
|
||||
return { type: 'tool-call', name, input: record.input, ...(callId ? { callId } : {}) }
|
||||
}
|
||||
case 'tool_result':
|
||||
return toolResultBlock(record)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -113,21 +114,29 @@ export function NativeChatDiffCard({
|
||||
file,
|
||||
revealSignal,
|
||||
onReveal,
|
||||
initiallyExpanded = false
|
||||
initiallyExpanded = false,
|
||||
disclosureKey
|
||||
}: {
|
||||
file: NativeChatEditFile
|
||||
revealSignal?: number
|
||||
onReveal?: (element: HTMLElement) => void
|
||||
initiallyExpanded?: boolean
|
||||
/** Identity this card's open state is remembered under while it is unmounted. */
|
||||
disclosureKey?: string
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(initiallyExpanded)
|
||||
const { open: expanded, setOpen: setExpanded } = useNativeChatDisclosure(
|
||||
disclosureKey,
|
||||
initiallyExpanded
|
||||
)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (revealSignal && cardRef.current) {
|
||||
setExpanded(true)
|
||||
// Reported from the card, not the row: a turn that touched four files must
|
||||
// land on the one that was asked for, and only the card knows where it is.
|
||||
onReveal?.(cardRef.current)
|
||||
}
|
||||
}, [revealSignal, onReveal])
|
||||
}, [revealSignal, onReveal, setExpanded])
|
||||
// Joining every row to seed the copy button is the card's most expensive
|
||||
// work, and a collapsed card renders none of those rows.
|
||||
const copyText = useMemo(() => patchText(file.lines), [file.lines])
|
||||
@@ -141,7 +150,7 @@ export function NativeChatDiffCard({
|
||||
<div ref={cardRef} className="my-1 overflow-hidden rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasBody && setExpanded((value) => !value)}
|
||||
onClick={() => hasBody && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 px-2 py-1 text-left',
|
||||
hasBody ? 'cursor-pointer hover:bg-accent/30' : 'cursor-default'
|
||||
|
||||
+7
-1
@@ -3,13 +3,14 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NativeChatProseModule from './native-chat-prose'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type * as UnifiedPatchModule from '../../../../shared/native-chat-unified-patch'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
// Counting real per-row work rather than a render counter: a future refactor could keep the
|
||||
// render count low while still re-deriving every row's markdown.
|
||||
@@ -43,6 +44,11 @@ vi.mock('../../../../shared/native-chat-unified-patch', async (importOriginal) =
|
||||
|
||||
const { NativeChatMessageList } = await import('./NativeChatMessageList')
|
||||
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(cleanup)
|
||||
|
||||
const TRANSCRIPT_LENGTH = 120
|
||||
|
||||
+7
-1
@@ -3,13 +3,19 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(cleanup)
|
||||
|
||||
function frame(id: number, status: string, overrides: { kind?: string; truncated?: boolean } = {}) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { subagentGroupFallbackText } from '../../../../shared/native-chat-subagent-summary'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
@@ -11,7 +11,13 @@ import type {
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(cleanup)
|
||||
|
||||
const session: NativeChatLiveSession = {
|
||||
|
||||
+7
-1
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type * as EditNormalization from '../../../../shared/native-chat-edit-normalize'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
const cost = vi.hoisted(() => ({ edits: 0, milliseconds: 0 }))
|
||||
vi.mock('../../../../shared/native-chat-edit-normalize', async (importOriginal) => {
|
||||
@@ -21,6 +22,11 @@ vi.mock('../../../../shared/native-chat-edit-normalize', async (importOriginal)
|
||||
}
|
||||
})
|
||||
const { NativeChatMessageList } = await import('./NativeChatMessageList')
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(cleanup)
|
||||
|
||||
const EMPTY: never[] = []
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { createNativeChatMessageListProjection } from './native-chat-message-list-projection'
|
||||
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
|
||||
import { nativeChatTaskListState } from './native-chat-task-list-state'
|
||||
import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
|
||||
import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames'
|
||||
import { MessageRow } from './NativeChatMessageRow'
|
||||
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
|
||||
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
|
||||
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
|
||||
@@ -17,6 +15,18 @@ import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import type { NativeChatTurnActivity } from './native-chat-turn-activity'
|
||||
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from './native-chat-disclosure-store'
|
||||
import { NativeChatTranscriptItems } from './NativeChatTranscriptItems'
|
||||
import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow'
|
||||
import {
|
||||
buildNativeChatTranscriptSlots,
|
||||
nativeChatSlotIndexOf
|
||||
} from './native-chat-transcript-slots'
|
||||
import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window'
|
||||
import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll'
|
||||
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
|
||||
@@ -26,15 +36,9 @@ import {
|
||||
type NativeChatDiffTarget,
|
||||
type NativeChatTurnDiff
|
||||
} from './native-chat-turn-diffs'
|
||||
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
|
||||
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
|
||||
|
||||
export { ProviderFrameRow } from './NativeChatTranscriptChrome'
|
||||
|
||||
function geometryOf(el: HTMLElement): ScrollGeometry {
|
||||
return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
|
||||
}
|
||||
|
||||
const MAX_EXPANDED_TURNS = 128
|
||||
|
||||
export function NativeChatMessageList({
|
||||
@@ -88,9 +92,8 @@ export function NativeChatMessageList({
|
||||
)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const [stuckToBottom, setStuckToBottom] = useState(true)
|
||||
const [showJump, setShowJump] = useState(false)
|
||||
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<string>>(new Set())
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
const toggleExpandedTurn = useCallback((turnKey: string) => {
|
||||
setExpandedTurnIds((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -109,8 +112,6 @@ export function NativeChatMessageList({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stuckToBottomRef = useRef(stuckToBottom)
|
||||
stuckToBottomRef.current = stuckToBottom
|
||||
const { hasMore, loadingEarlier, loadEarlier } = session
|
||||
|
||||
const projectMessages = useMemo(
|
||||
@@ -156,220 +157,170 @@ export function NativeChatMessageList({
|
||||
workingStartedAt: showTurnStatus ? workingStartedAt : null,
|
||||
settledTurns: showTurnStatus ? settledTurns : null
|
||||
})
|
||||
const lifecycleWorking = session.transcriptLifecycle?.state === 'working'
|
||||
const slots = useMemo(
|
||||
() =>
|
||||
buildNativeChatTranscriptSlots({
|
||||
messages,
|
||||
turnKeys,
|
||||
latestUserIndex,
|
||||
currentTurnKey,
|
||||
receipts,
|
||||
turnStatuses,
|
||||
turnDiffs,
|
||||
showTurnStatus,
|
||||
showTypingIndicator,
|
||||
isWorking,
|
||||
lifecycleWorking
|
||||
}),
|
||||
[
|
||||
currentTurnKey,
|
||||
isWorking,
|
||||
latestUserIndex,
|
||||
lifecycleWorking,
|
||||
messages,
|
||||
receipts,
|
||||
showTurnStatus,
|
||||
showTypingIndicator,
|
||||
turnDiffs,
|
||||
turnKeys,
|
||||
turnStatuses
|
||||
]
|
||||
)
|
||||
const transcriptWindow = useNativeChatTranscriptWindow({
|
||||
scrollRef,
|
||||
slots,
|
||||
revealIndex: nativeChatSlotIndexOf(slots, revealedDiff?.messageId)
|
||||
})
|
||||
const { showJump, onScroll, scrollToBottom, scrollMessageToTop } = useNativeChatTranscriptScroll({
|
||||
scrollRef,
|
||||
contentRef,
|
||||
itemCount: slots.length,
|
||||
isWorking,
|
||||
showTypingIndicator,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlier,
|
||||
alignToViewportTop: transcriptWindow.alignToViewportTop
|
||||
})
|
||||
|
||||
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const geometry = geometryOf(el)
|
||||
const stick = isNearBottom(geometry)
|
||||
setStuckToBottom(stick)
|
||||
setShowJump(shouldShowJumpToLatest(stick, geometry))
|
||||
// Near the top — page in older history, anchoring the current position so the
|
||||
// prepend doesn't yank the view.
|
||||
if (geometry.scrollTop < 80 && hasMore && !loadingEarlier) {
|
||||
prependAnchorRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }
|
||||
loadEarlier()
|
||||
}
|
||||
}, [hasMore, loadingEarlier, loadEarlier])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.scrollTop = el.scrollHeight
|
||||
setStuckToBottom(true)
|
||||
setShowJump(false)
|
||||
}, [])
|
||||
|
||||
// Align a single message's top to the top of the scroll viewport.
|
||||
const scrollMessageToTop = useCallback((el: HTMLElement) => {
|
||||
const container = scrollRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
stuckToBottomRef.current = false
|
||||
setStuckToBottom(false)
|
||||
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||
container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && prependAnchorRef.current) {
|
||||
// Preserve the viewport: shift scrollTop by however much taller the content
|
||||
// got, so the message the user was reading stays put.
|
||||
const grew = el.scrollHeight - prependAnchorRef.current.scrollHeight
|
||||
el.scrollTop = prependAnchorRef.current.scrollTop + grew
|
||||
prependAnchorRef.current = null
|
||||
return
|
||||
}
|
||||
if (stuckToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [messages.length, isWorking, showTypingIndicator, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (stuckToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
handleScroll()
|
||||
}
|
||||
})
|
||||
// Observe the growing content, not just the fixed-height viewport, so an
|
||||
// in-place streaming growth is seen; also watch the viewport for reflows.
|
||||
observer.observe(el)
|
||||
if (contentRef.current) {
|
||||
observer.observe(contentRef.current)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [handleScroll, scrollToBottom])
|
||||
const rowContext = useMemo<NativeChatTranscriptRowContext>(
|
||||
() => ({
|
||||
expandSignal,
|
||||
showTurnStatus,
|
||||
revealedDiff,
|
||||
taskListPredecessors,
|
||||
expandedTurnIds,
|
||||
failedDeliveryMessageIds,
|
||||
allowFileUriLinks,
|
||||
runtimeContext,
|
||||
onLinkClick,
|
||||
onToggleExpandedTurn: toggleExpandedTurn,
|
||||
onScrollMessageToTop: scrollMessageToTop,
|
||||
onRevealDiff: revealDiff
|
||||
}),
|
||||
[
|
||||
allowFileUriLinks,
|
||||
expandSignal,
|
||||
expandedTurnIds,
|
||||
failedDeliveryMessageIds,
|
||||
onLinkClick,
|
||||
revealDiff,
|
||||
revealedDiff,
|
||||
runtimeContext,
|
||||
scrollMessageToTop,
|
||||
showTurnStatus,
|
||||
taskListPredecessors,
|
||||
toggleExpandedTurn
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="scrollbar-sleek h-full overflow-y-auto [scrollbar-gutter:stable_both-edges] px-3 pt-10 pb-4 sm:px-4"
|
||||
>
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={contentRef}
|
||||
// Why: matches composer column (max-w-4xl) with 5px horizontal inset
|
||||
// on each side so content is slightly narrower than the input box.
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-[5px]"
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
// Named so measurement can find the scroll root without depending on
|
||||
// which utility class happens to make it scroll.
|
||||
data-native-chat-scroll
|
||||
className="scrollbar-sleek relative h-full overflow-y-auto [scrollbar-gutter:stable_both-edges]"
|
||||
// Why: `zoom` scales the chat transcript's text and layout together,
|
||||
// scoped to this container so the rest of the app is untouched. It's
|
||||
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
|
||||
// scoped to this pane so the rest of the app is untouched. It sits on
|
||||
// the scroll container rather than the content inside it so that
|
||||
// scroll offsets and row measurements share one coordinate space —
|
||||
// measuring zoomed content against an unzoomed scroller misplaces the
|
||||
// window by exactly `fontScale`. (Chromium/Electron only.)
|
||||
style={{ zoom: fontScale }}
|
||||
>
|
||||
{hasMore ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEarlier}
|
||||
disabled={loadingEarlier}
|
||||
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loadingEarlier
|
||||
? translate('components.native-chat.loadingEarlier', 'Loading…')
|
||||
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
|
||||
</button>
|
||||
<div className="px-3 pt-10 pb-4 sm:px-4">
|
||||
<div
|
||||
ref={contentRef}
|
||||
// Why: matches composer column (max-w-4xl) with 5px horizontal inset
|
||||
// on each side so content is slightly narrower than the input box.
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-[5px]"
|
||||
>
|
||||
{hasMore ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEarlier}
|
||||
disabled={loadingEarlier}
|
||||
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loadingEarlier
|
||||
? translate('components.native-chat.loadingEarlier', 'Loading…')
|
||||
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<NativeChatTranscriptItems
|
||||
slots={slots}
|
||||
context={rowContext}
|
||||
window={transcriptWindow}
|
||||
/>
|
||||
{showTurnStatus &&
|
||||
latestUserIndex === -1 &&
|
||||
turnStatuses.active &&
|
||||
showTypingIndicator ? (
|
||||
<NativeChatWorkingStatus
|
||||
startedAt={turnStatuses.active.startedAt}
|
||||
thinking={turnStatuses.active.thinking}
|
||||
workedSeconds={turnStatuses.active.workedSeconds}
|
||||
/>
|
||||
) : null}
|
||||
{showTurnStatus && isWorking ? (
|
||||
<NativeChatTurnActivityLine activity={turnActivity} />
|
||||
) : null}
|
||||
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((message, index) => {
|
||||
const turnKey = turnKeys[index]
|
||||
const isCurrentTurn = currentTurnKey
|
||||
? turnKey === currentTurnKey
|
||||
: turnKey === undefined
|
||||
const status =
|
||||
index === latestUserIndex
|
||||
? turnStatuses.active
|
||||
: message.role === 'user' && turnKey
|
||||
? turnStatuses.completedByTurn[turnKey]
|
||||
: undefined
|
||||
const receipt = receipts.get(message.id)
|
||||
const turnDiff =
|
||||
turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{receipt ? (
|
||||
<NativeChatResolutionReceipt body={receipt} />
|
||||
) : (
|
||||
<MessageRow
|
||||
message={message}
|
||||
previousTodoWrite={taskListPredecessors.get(message.id)?.todowrite}
|
||||
previousUpdatePlan={taskListPredecessors.get(message.id)?.update_plan}
|
||||
revealedDiff={
|
||||
revealedDiff?.messageId === message.id ? revealedDiff : undefined
|
||||
}
|
||||
expandSignal={expandSignal}
|
||||
// A missing transcript lifecycle is not evidence that the turn
|
||||
// ended. Structured sessions and legacy live hooks still expose
|
||||
// the authoritative session-level working state.
|
||||
activeTurnIsWorking={
|
||||
showTurnStatus &&
|
||||
isCurrentTurn &&
|
||||
(isWorking || session.transcriptLifecycle?.state === 'working')
|
||||
}
|
||||
onScrollMessageToTop={scrollMessageToTop}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
|
||||
structuredActivityUi={showTurnStatus}
|
||||
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
|
||||
runtimeContext={runtimeContext}
|
||||
/>
|
||||
)}
|
||||
{showTurnStatus &&
|
||||
status &&
|
||||
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
|
||||
<NativeChatWorkingStatus
|
||||
startedAt={status.startedAt}
|
||||
thinking={status.thinking}
|
||||
workedSeconds={status.workedSeconds}
|
||||
expanded={turnKey ? expandedTurnIds.has(turnKey) : false}
|
||||
onToggleExpanded={
|
||||
status.workedSeconds != null && turnKey
|
||||
? () => toggleExpandedTurn(turnKey)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{turnDiff ? (
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={revealDiff} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{showTurnStatus &&
|
||||
latestUserIndex === -1 &&
|
||||
turnStatuses.active &&
|
||||
showTypingIndicator ? (
|
||||
<NativeChatWorkingStatus
|
||||
startedAt={turnStatuses.active.startedAt}
|
||||
thinking={turnStatuses.active.thinking}
|
||||
workedSeconds={turnStatuses.active.workedSeconds}
|
||||
/>
|
||||
) : null}
|
||||
{showTurnStatus && isWorking ? (
|
||||
<NativeChatTurnActivityLine activity={turnActivity} />
|
||||
) : null}
|
||||
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showJump ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
|
||||
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showJump ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
|
||||
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
|
||||
</button>
|
||||
{taskListState.list && taskListState.list.tasks.length > 0 ? (
|
||||
<div className="shrink-0 px-3 pb-2 sm:px-4">
|
||||
<div className="mx-auto w-full max-w-4xl" style={{ zoom: fontScale }}>
|
||||
<NativeChatTaskList
|
||||
key={session.sessionId}
|
||||
list={taskListState.list}
|
||||
presentation="composer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{taskListState.list && taskListState.list.tasks.length > 0 ? (
|
||||
<div className="shrink-0 px-3 pb-2 sm:px-4">
|
||||
<div className="mx-auto w-full max-w-4xl" style={{ zoom: fontScale }}>
|
||||
<NativeChatTaskList
|
||||
key={session.sessionId}
|
||||
list={taskListState.list}
|
||||
presentation="composer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
@@ -9,8 +9,14 @@ import type {
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
const scrollTo = vi.fn()
|
||||
let restoreViewport = (): void => {}
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
afterAll(() => restoreViewport())
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
|
||||
@@ -3,12 +3,21 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
let restoreViewport = (): void => {}
|
||||
|
||||
beforeAll(() => {
|
||||
restoreViewport = installNativeChatMessageListTestViewport()
|
||||
})
|
||||
|
||||
afterAll(() => restoreViewport())
|
||||
|
||||
const session: NativeChatLiveSession = {
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
|
||||
import {
|
||||
estimateNativeChatRowHeight,
|
||||
NATIVE_CHAT_ROW_GAP_PX,
|
||||
nativeChatRowContentMetrics
|
||||
} from './native-chat-row-height-estimate'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const VIEWPORT_PX = 600
|
||||
const TRANSCRIPT_LENGTH = 200
|
||||
|
||||
/** Everything the document holds below the last row: the transcript column's
|
||||
* trailing chrome and the scroll root's bottom padding. Non-zero on purpose —
|
||||
* the document's bottom sits past the window's last row, which is exactly where
|
||||
* a pin computed from the virtualizer's totals and one computed from the
|
||||
* document disagree. */
|
||||
const BELOW_TRANSCRIPT_PX = 24
|
||||
|
||||
/** Heights the stubbed layout reports per row index, when a case wants a row to
|
||||
* measure as something other than its estimate. Empty means "every row at its
|
||||
* estimate", which is what every non-growth case wants. */
|
||||
let measuredRowHeights: readonly number[] = []
|
||||
|
||||
function marker(index: number): NativeChatMessage {
|
||||
return {
|
||||
id: `message-${index}`,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `marker-${index}` }],
|
||||
timestamp: index + 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
const ROW_PX = estimateNativeChatRowHeight(nativeChatRowContentMetrics(marker(0)), {
|
||||
hasReceipt: false,
|
||||
hasStatus: false,
|
||||
hasTurnDiff: false
|
||||
})
|
||||
const ROW_PITCH_PX = ROW_PX + NATIVE_CHAT_ROW_GAP_PX
|
||||
|
||||
/** Replace a layout property on every element, and hand back the undo. */
|
||||
function overrideLayoutProperty(name: string, descriptor: PropertyDescriptor): () => void {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)
|
||||
Object.defineProperty(HTMLElement.prototype, name, { configurable: true, ...descriptor })
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, original)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The spacer's reserved height, which is the transcript's whole rendered height:
|
||||
* windowed rows are absolutely positioned inside it, so a row growing in place
|
||||
* reaches the document only through the height the window reserves for it. */
|
||||
function reservedTranscriptHeight(root: ParentNode): number {
|
||||
const spacer = root.querySelector<HTMLElement>('[data-native-chat-window]')
|
||||
return spacer ? Number.parseFloat(spacer.style.height) || 0 : 0
|
||||
}
|
||||
|
||||
// The virtualizer measures with `offsetHeight` — not `clientHeight`, not a
|
||||
// bounding rect — so that is the one thing a DOM without layout has to answer
|
||||
// for windowing to engage at all. Rows report the height their own estimate
|
||||
// predicted, which keeps the totals exact and independent of which rows happen
|
||||
// to have been mounted long enough to be measured; `measuredRowHeights` is how a
|
||||
// case says a row measures as something else.
|
||||
//
|
||||
// `scrollGeometry` additionally gives the scroll root a document to scroll: a
|
||||
// height, a viewport, and a `scrollTop` that clamps the way a real one does.
|
||||
// Off by default, because a transcript with a real document opens pinned to its
|
||||
// bottom and the cases above are about where the window sits, not where it lands.
|
||||
function stubLayout({
|
||||
scrollGeometry = false,
|
||||
viewportHeight = () => VIEWPORT_PX
|
||||
}: {
|
||||
scrollGeometry?: boolean
|
||||
viewportHeight?: () => number
|
||||
} = {}): () => void {
|
||||
const scrollTops = new WeakMap<HTMLElement, number>()
|
||||
const restores = [
|
||||
overrideLayoutProperty('offsetHeight', {
|
||||
get(this: HTMLElement): number {
|
||||
if (this.hasAttribute('data-native-chat-scroll')) {
|
||||
return viewportHeight()
|
||||
}
|
||||
if (this.hasAttribute('data-native-chat-window')) {
|
||||
return reservedTranscriptHeight(this.parentElement ?? this)
|
||||
}
|
||||
const index = this.dataset.index
|
||||
if (index !== undefined) {
|
||||
return measuredRowHeights[Number(index)] ?? ROW_PX
|
||||
}
|
||||
// The transcript column: as tall as the window it wraps, plus what sits
|
||||
// under it. This is the element the list observes for streamed growth.
|
||||
return this.classList.contains('max-w-4xl')
|
||||
? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX
|
||||
: 0
|
||||
}
|
||||
})
|
||||
]
|
||||
if (scrollGeometry) {
|
||||
restores.push(
|
||||
overrideLayoutProperty('clientHeight', {
|
||||
get(this: HTMLElement): number {
|
||||
return this.hasAttribute('data-native-chat-scroll') ? viewportHeight() : 0
|
||||
}
|
||||
}),
|
||||
overrideLayoutProperty('scrollHeight', {
|
||||
get(this: HTMLElement): number {
|
||||
return this.hasAttribute('data-native-chat-scroll')
|
||||
? reservedTranscriptHeight(this) + BELOW_TRANSCRIPT_PX
|
||||
: 0
|
||||
}
|
||||
}),
|
||||
overrideLayoutProperty('scrollTop', {
|
||||
get(this: HTMLElement): number {
|
||||
return scrollTops.get(this) ?? 0
|
||||
},
|
||||
set(this: HTMLElement, value: number): void {
|
||||
// A browser clamps; without this `scrollTop = scrollHeight` would park
|
||||
// the view past the end and every distance-from-bottom would read 0.
|
||||
const max = Math.max(0, this.scrollHeight - this.clientHeight)
|
||||
scrollTops.set(this, Math.min(Math.max(0, value), max))
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
for (const restore of restores.toReversed()) {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FakeResizeObservation = {
|
||||
callback: ResizeObserverCallback
|
||||
/** Target -> height last delivered. -1 means "never", so the first flush
|
||||
* delivers, the way a real observer's initial callback does. */
|
||||
observed: Map<Element, number>
|
||||
}
|
||||
|
||||
const resizeObservations = new Set<FakeResizeObservation>()
|
||||
|
||||
/** happy-dom's ResizeObserver never fires, so nothing that re-measures ever runs.
|
||||
* This one records what production observes and delivers only when a target's
|
||||
* height actually changed — the browser's own rule — and only when a test says
|
||||
* a frame was painted. Entries carry no `borderBoxSize`, so the virtualizer
|
||||
* falls back to `offsetHeight`, which is the path being modelled. */
|
||||
function stubResizeObserver(): () => void {
|
||||
const original = window.ResizeObserver
|
||||
class TestResizeObserver {
|
||||
private readonly observation: FakeResizeObservation
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.observation = { callback, observed: new Map() }
|
||||
resizeObservations.add(this.observation)
|
||||
}
|
||||
observe(target: Element): void {
|
||||
this.observation.observed.set(target, -1)
|
||||
}
|
||||
unobserve(target: Element): void {
|
||||
this.observation.observed.delete(target)
|
||||
}
|
||||
disconnect(): void {
|
||||
this.observation.observed.clear()
|
||||
resizeObservations.delete(this.observation)
|
||||
}
|
||||
}
|
||||
window.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver
|
||||
return () => {
|
||||
resizeObservations.clear()
|
||||
window.ResizeObserver = original
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver one round of resize callbacks; true when anything was delivered. */
|
||||
function deliverResizes(): boolean {
|
||||
let delivered = false
|
||||
// A copy: a callback may disconnect its own observer mid-delivery.
|
||||
for (const observation of Array.from(resizeObservations)) {
|
||||
const entries: ResizeObserverEntry[] = []
|
||||
for (const [target, lastHeight] of observation.observed) {
|
||||
const height = (target as HTMLElement).offsetHeight
|
||||
if (height !== lastHeight) {
|
||||
observation.observed.set(target, height)
|
||||
entries.push({ target } as unknown as ResizeObserverEntry)
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
delivered = true
|
||||
observation.callback(entries, undefined as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
function session(messages: NativeChatMessage[]): NativeChatLiveSession {
|
||||
return {
|
||||
messages,
|
||||
status: 'ready',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase: 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
function list(messages: NativeChatMessage[]): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={session(messages)}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Reads the window, and refuses to pass if there is no window to read.
|
||||
*
|
||||
* Without this a change to the usability gate would quietly send every case
|
||||
* below down the whole-transcript path, where "fewer rows than messages" is
|
||||
* false but every other assertion still holds. */
|
||||
function windowState(container: HTMLElement): { totalSize: number; indexes: number[] } {
|
||||
const spacer = container.querySelector<HTMLElement>('[data-native-chat-window]')
|
||||
if (!spacer) {
|
||||
throw new Error('transcript is not windowed: no spacer, every row is mounted')
|
||||
}
|
||||
const totalSize = Number.parseFloat(spacer.style.height)
|
||||
if (!(totalSize > 0)) {
|
||||
throw new Error(`transcript reserved no height (${spacer.style.height})`)
|
||||
}
|
||||
return {
|
||||
totalSize,
|
||||
indexes: Array.from(container.querySelectorAll<HTMLElement>('[data-index]'))
|
||||
.map((row) => Number(row.dataset.index))
|
||||
.sort((left, right) => left - right)
|
||||
}
|
||||
}
|
||||
|
||||
/** happy-dom fires no scroll event for an assignment to `scrollTop`. */
|
||||
function scrollTranscript(container: HTMLElement, top: number): void {
|
||||
const scroller = container.querySelector<HTMLElement>('[data-native-chat-scroll]')
|
||||
if (!scroller) {
|
||||
throw new Error('no transcript scroll root')
|
||||
}
|
||||
scroller.scrollTop = top
|
||||
fireEvent.scroll(scroller)
|
||||
}
|
||||
|
||||
describe('windowed transcript', () => {
|
||||
let restoreLayout = (): void => {}
|
||||
beforeEach(() => {
|
||||
restoreLayout = stubLayout()
|
||||
})
|
||||
afterEach(() => {
|
||||
restoreLayout()
|
||||
})
|
||||
|
||||
const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index))
|
||||
|
||||
it('mounts a window over the transcript rather than all of it', () => {
|
||||
const { container } = render(list(transcript))
|
||||
const { indexes } = windowState(container)
|
||||
|
||||
expect(indexes.length).toBeGreaterThan(0)
|
||||
expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
|
||||
expect(indexes).toContain(0)
|
||||
expect(screen.getByText('marker-0')).toBeInTheDocument()
|
||||
expect(screen.queryByText(`marker-${TRANSCRIPT_LENGTH - 2}`)).toBeNull()
|
||||
})
|
||||
|
||||
// One gap per pair of rows, and none after the last one. The other half of
|
||||
// this — that a row's own reservation does not include the gap as well — is
|
||||
// pinned on the estimate itself, where it can be seen without layout.
|
||||
it('reserves each row once and one gap between each pair', () => {
|
||||
const { container } = render(list(transcript))
|
||||
|
||||
expect(windowState(container).totalSize).toBe(
|
||||
TRANSCRIPT_LENGTH * ROW_PX + (TRANSCRIPT_LENGTH - 1) * NATIVE_CHAT_ROW_GAP_PX
|
||||
)
|
||||
})
|
||||
|
||||
it('moves the mounted rows to bracket the offset the reader scrolled to', () => {
|
||||
const { container } = render(list(transcript))
|
||||
const offset = 5000
|
||||
scrollTranscript(container, offset)
|
||||
const { indexes } = windowState(container)
|
||||
const focused = Math.floor(offset / ROW_PITCH_PX)
|
||||
|
||||
expect(indexes).toContain(focused)
|
||||
expect(indexes[0]).toBeLessThanOrEqual(focused)
|
||||
expect(indexes.at(-1)).toBeGreaterThanOrEqual(focused)
|
||||
expect(indexes).not.toContain(0)
|
||||
expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
|
||||
})
|
||||
|
||||
// The live row announces a running tool through `aria-live`, which says nothing
|
||||
// from a row that is not in the document.
|
||||
it('keeps the newest row mounted after the reader scrolls away from it', () => {
|
||||
const { container } = render(list(transcript))
|
||||
scrollTranscript(container, 5000)
|
||||
|
||||
expect(windowState(container).indexes).toContain(TRANSCRIPT_LENGTH - 1)
|
||||
})
|
||||
|
||||
it('gives no slot to a message that draws nothing', () => {
|
||||
const withBlanks = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
|
||||
index % 4 === 0
|
||||
? { ...marker(index), blocks: [{ type: 'text' as const, text: '' }] }
|
||||
: marker(index)
|
||||
)
|
||||
const drawn = TRANSCRIPT_LENGTH - TRANSCRIPT_LENGTH / 4
|
||||
const { container } = render(list(withBlanks))
|
||||
const { totalSize, indexes } = windowState(container)
|
||||
|
||||
expect(totalSize).toBe(drawn * ROW_PX + (drawn - 1) * NATIVE_CHAT_ROW_GAP_PX)
|
||||
expect(indexes.at(-1)).toBeLessThanOrEqual(drawn - 1)
|
||||
})
|
||||
|
||||
it('still has the tool run open when the row carrying it comes back', () => {
|
||||
const withTool = [...transcript]
|
||||
withTool[1] = {
|
||||
...marker(1),
|
||||
blocks: [
|
||||
{ type: 'text', text: 'marker-1' },
|
||||
{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }
|
||||
]
|
||||
}
|
||||
const { container } = render(list(withTool))
|
||||
|
||||
const header = screen.getByRole('button', { name: /1×/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(header)
|
||||
expect(screen.getByRole('button', { name: /1×/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
scrollTranscript(container, 5000)
|
||||
expect(windowState(container).indexes).not.toContain(1)
|
||||
expect(screen.queryByRole('button', { name: /1×/ })).toBeNull()
|
||||
|
||||
scrollTranscript(container, 0)
|
||||
expect(screen.getByRole('button', { name: /1×/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// The reveal chain runs message -> tool run -> diff card and lands on a card in
|
||||
// a DIFFERENT, earlier message than the rollup that was clicked. Under windowing
|
||||
// that message may not be mounted to be pointed at, so the reveal names it by id
|
||||
// and the row is pinned into the window until the card can answer for itself.
|
||||
describe('revealing a diff from a turn rollup', () => {
|
||||
let restoreLayout = (): void => {}
|
||||
beforeEach(() => {
|
||||
restoreLayout = stubLayout()
|
||||
})
|
||||
afterEach(() => {
|
||||
restoreLayout()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) {
|
||||
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
|
||||
}
|
||||
|
||||
const patch = '@@ -1 +1 @@\n-before\n+after'
|
||||
const items: AgentJournalRenderItem[] = [
|
||||
journalItem(
|
||||
'user',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] },
|
||||
1
|
||||
),
|
||||
journalItem(
|
||||
'diff',
|
||||
{
|
||||
kind: 'diff',
|
||||
path: 'src/a.ts',
|
||||
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
|
||||
},
|
||||
2
|
||||
),
|
||||
...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
|
||||
journalItem(
|
||||
`tail-${index}`,
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `marker-${index}` }] },
|
||||
index + 3
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
it('mounts the row a reveal names even when the window has left it behind', () => {
|
||||
const scrollTo = vi.fn()
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
|
||||
const { container } = render(
|
||||
<NativeChatMessageList
|
||||
session={session(projectStructuredItemsToNativeChat(items))}
|
||||
journalItems={items}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
// The rollup rides the turn's last row, which is pinned; the diff it points
|
||||
// at is near the top and long gone from the window.
|
||||
scrollTranscript(container, 4000)
|
||||
expect(screen.queryByText('Edited file')).toBeNull()
|
||||
const mountedBefore = windowState(container).indexes.length
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
scrollTo.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
|
||||
expect(screen.getByText('Edited file')).toBeInTheDocument()
|
||||
expect(screen.getByText('after')).toBeInTheDocument()
|
||||
expect(scrollTo).toHaveBeenCalled()
|
||||
// Pinned, not paged to: the window is still a window.
|
||||
expect(windowState(container).indexes.length).toBeLessThanOrEqual(mountedBefore + 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcript with a hidden scroll root', () => {
|
||||
const transcript = Array.from({ length: 40 }, (_, index) => marker(index))
|
||||
|
||||
it('keeps the transcript bounded and rehydrates when the viewport becomes measurable', () => {
|
||||
let viewportHeight = 0
|
||||
const restoreLayout = stubLayout({ viewportHeight: () => viewportHeight })
|
||||
const restoreResizeObserver = stubResizeObserver()
|
||||
try {
|
||||
const { container } = render(list(transcript))
|
||||
|
||||
expect(container.querySelector('[data-native-chat-window]')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(0)
|
||||
expect(screen.queryByText(/^marker-/)).toBeNull()
|
||||
const column = container.querySelector('.max-w-4xl')
|
||||
expect(column?.children).toHaveLength(1)
|
||||
|
||||
viewportHeight = VIEWPORT_PX
|
||||
act(() => {
|
||||
deliverResizes()
|
||||
})
|
||||
const { indexes } = windowState(container)
|
||||
expect(indexes.length).toBeGreaterThan(0)
|
||||
expect(indexes.length).toBeLessThan(transcript.length)
|
||||
} finally {
|
||||
restoreResizeObserver()
|
||||
restoreLayout()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A row that grows in place: the same message id, more content, a taller measured
|
||||
// box — what a streaming reply looks like to the window. Whole-message appends
|
||||
// arrive at their final height and are a different case; this is the one where
|
||||
// the row the reader is looking at keeps changing size underneath them.
|
||||
//
|
||||
// Two mechanisms are supposed to hold the pin, and both are exercised here: the
|
||||
// list's own resize observer on the transcript column (which re-runs
|
||||
// `scrollToBottom` against the document) and the virtualizer's end anchor (which
|
||||
// compensates `scrollTop` by the growth when the view was already at the end).
|
||||
describe('a row growing in place while the view is pinned to the bottom', () => {
|
||||
const TAIL_INDEX = TRANSCRIPT_LENGTH - 1
|
||||
const GROWTH_STEPS = 24
|
||||
const LINES_PER_STEP = 12
|
||||
/** One wrapped prose line. Content and measured height grow from this one
|
||||
* number, so a step that adds lines is a step that adds pixels. */
|
||||
const STREAM_LINE_PX = 22
|
||||
/** Every row but the growing one measures at its estimate, so the reserved
|
||||
* total is arithmetic rather than a snapshot. */
|
||||
const BASE_TOTAL_PX =
|
||||
(TRANSCRIPT_LENGTH - 1) * ROW_PX + (TRANSCRIPT_LENGTH - 1) * NATIVE_CHAT_ROW_GAP_PX
|
||||
|
||||
/** Fixed so a re-render never restamps the turn and moves the status row. */
|
||||
const TURN_STARTED_AT = Date.now()
|
||||
|
||||
const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index))
|
||||
|
||||
function tailHeightAt(step: number): number {
|
||||
return Math.max(ROW_PX, (1 + step * LINES_PER_STEP) * STREAM_LINE_PX)
|
||||
}
|
||||
|
||||
function transcriptAt(step: number): NativeChatMessage[] {
|
||||
const lines = Array.from(
|
||||
{ length: step * LINES_PER_STEP },
|
||||
(_, index) => `streamed line ${index}`
|
||||
)
|
||||
const next = [...transcript]
|
||||
next[TAIL_INDEX] = {
|
||||
...marker(TAIL_INDEX),
|
||||
blocks: [{ type: 'text', text: [`marker-${TAIL_INDEX}`, ...lines].join('\n') }]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function streamingList(step: number): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={session(transcriptAt(step))}
|
||||
isWorking
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
workingStartedAt={TURN_STARTED_AT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement): HTMLElement {
|
||||
const scroller = container.querySelector<HTMLElement>('[data-native-chat-scroll]')
|
||||
if (!scroller) {
|
||||
throw new Error('no transcript scroll root')
|
||||
}
|
||||
return scroller
|
||||
}
|
||||
|
||||
/** One painted frame, repeated to a fixed point: deliver the resize callbacks
|
||||
* the growth caused, then fire the scroll event a browser fires for any
|
||||
* `scrollTop` the code wrote itself. Refusing to settle is a failure in its
|
||||
* own right — that is the view oscillating. */
|
||||
function paint(container: HTMLElement): void {
|
||||
const scroller = scrollRoot(container)
|
||||
let lastScrollTop = scroller.scrollTop
|
||||
for (let pass = 0; pass < 12; pass += 1) {
|
||||
let changed = false
|
||||
act(() => {
|
||||
changed = deliverResizes()
|
||||
})
|
||||
if (scroller.scrollTop !== lastScrollTop) {
|
||||
lastScrollTop = scroller.scrollTop
|
||||
fireEvent.scroll(scroller)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error('the transcript never settled: resize and scroll kept moving it')
|
||||
}
|
||||
|
||||
function distanceFromBottom(container: HTMLElement): number {
|
||||
const scroller = scrollRoot(container)
|
||||
return scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop
|
||||
}
|
||||
|
||||
function setMeasuredTail(step: number): void {
|
||||
const heights = Array.from({ length: TRANSCRIPT_LENGTH }, () => ROW_PX)
|
||||
heights[TAIL_INDEX] = tailHeightAt(step)
|
||||
measuredRowHeights = heights
|
||||
}
|
||||
|
||||
let restoreLayout = (): void => {}
|
||||
let restoreResizeObserver = (): void => {}
|
||||
beforeEach(() => {
|
||||
restoreLayout = stubLayout({ scrollGeometry: true })
|
||||
restoreResizeObserver = stubResizeObserver()
|
||||
setMeasuredTail(0)
|
||||
})
|
||||
afterEach(() => {
|
||||
restoreResizeObserver()
|
||||
restoreLayout()
|
||||
measuredRowHeights = []
|
||||
})
|
||||
|
||||
it('holds the pin, the mount and the reserved total at every frame of the growth', () => {
|
||||
setMeasuredTail(0)
|
||||
const { container, rerender } = render(streamingList(0))
|
||||
paint(container)
|
||||
|
||||
expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_BOTTOM_THRESHOLD_PX)
|
||||
expect(windowState(container).totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(0))
|
||||
|
||||
const frames: { step: number; tail: number; total: number; distance: number }[] = []
|
||||
for (let step = 1; step <= GROWTH_STEPS; step += 1) {
|
||||
setMeasuredTail(step)
|
||||
rerender(streamingList(step))
|
||||
paint(container)
|
||||
|
||||
const { totalSize, indexes } = windowState(container)
|
||||
const distance = distanceFromBottom(container)
|
||||
frames.push({ step, tail: tailHeightAt(step), total: totalSize, distance })
|
||||
|
||||
// Pinned: the reader is still looking at the bottom of the row.
|
||||
expect(distance).toBeLessThanOrEqual(NATIVE_CHAT_BOTTOM_THRESHOLD_PX)
|
||||
// Mounted: never swapped for reserved space while it is the live row.
|
||||
expect(indexes).toContain(TAIL_INDEX)
|
||||
expect(screen.getByText(/streamed line 0/)).toBeInTheDocument()
|
||||
// Tracking: the reservation follows the measurement, not the estimate.
|
||||
expect(totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(step))
|
||||
// Still a window, not the whole transcript remounted by the growth.
|
||||
expect(indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
|
||||
}
|
||||
|
||||
expect(frames).toHaveLength(GROWTH_STEPS)
|
||||
expect(frames.at(-1)?.tail).toBeGreaterThan(VIEWPORT_PX * 10)
|
||||
expect(Math.max(...frames.map((frame) => frame.distance))).toBeLessThanOrEqual(
|
||||
NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a reader who scrolled up where they were, however far the row grows', () => {
|
||||
setMeasuredTail(4)
|
||||
const { container, rerender } = render(streamingList(4))
|
||||
paint(container)
|
||||
|
||||
const readingAt = 2000
|
||||
scrollTranscript(container, readingAt)
|
||||
paint(container)
|
||||
expect(distanceFromBottom(container)).toBeGreaterThan(NATIVE_CHAT_BOTTOM_THRESHOLD_PX)
|
||||
expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
|
||||
|
||||
for (let step = 5; step <= GROWTH_STEPS; step += 1) {
|
||||
setMeasuredTail(step)
|
||||
rerender(streamingList(step))
|
||||
paint(container)
|
||||
|
||||
const { totalSize, indexes } = windowState(container)
|
||||
// Not yanked: the offset the reader chose is the offset they still have.
|
||||
expect(scrollRoot(container).scrollTop).toBe(readingAt)
|
||||
// The row is off screen but still measured, which is what keeps the
|
||||
// reserved total — and so the scrollbar — honest while it grows.
|
||||
expect(indexes).toContain(TAIL_INDEX)
|
||||
expect(totalSize).toBe(BASE_TOTAL_PX + tailHeightAt(step))
|
||||
}
|
||||
|
||||
expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,17 @@
|
||||
import { memo, useCallback, useMemo, useRef } from 'react'
|
||||
import { memo, useCallback, useRef } from 'react'
|
||||
import CommentMarkdown, {
|
||||
type CommentMarkdownLinkClickHandler
|
||||
} from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
subagentGroupBlocks
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
type NativeChatMessage,
|
||||
type NativeChatToolCallBlock
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
import { deriveNativeChatRowContent } from './native-chat-row-content'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
|
||||
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
|
||||
import { nativeChatProseToMarkdown } from './native-chat-prose'
|
||||
import {
|
||||
NativeChatAgentControls,
|
||||
NativeChatImageAttachments,
|
||||
@@ -62,32 +56,11 @@ export const MessageRow = memo(function MessageRow({
|
||||
runtimeContext?: RuntimeFileOperationArgs | null
|
||||
}): React.JSX.Element | null {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null)
|
||||
// One pass per block set: a streaming turn re-renders this row on every frame, and these
|
||||
// derivations used to re-run each time even though `message.blocks` had not changed.
|
||||
const { hasImages, markdown, prose, subagentGroups, tools } = useMemo(() => {
|
||||
const split = splitNativeChatBlocks(message.blocks)
|
||||
const groups = subagentGroupBlocks(split.prose)
|
||||
// A spawn-group row carries a plain-text twin so a client without the block
|
||||
// type still reads the roster. This one draws the block, so the twin is
|
||||
// dropped rather than printed beside it — only the twin, never the prose
|
||||
// beside it: the block is provider-agnostic, so a lane that folds a roster
|
||||
// into a message with real text must not lose that text here.
|
||||
const prose =
|
||||
groups.length === 0
|
||||
? split.prose
|
||||
: split.prose.filter(
|
||||
(block) =>
|
||||
!isSubagentGroupBlock(block) &&
|
||||
!(block.type === 'text' && isSubagentGroupFallbackText(block.text))
|
||||
)
|
||||
return {
|
||||
tools: split.tools,
|
||||
prose,
|
||||
subagentGroups: groups,
|
||||
markdown: nativeChatProseToMarkdown(prose),
|
||||
hasImages: prose.some((block) => block.type === 'image-ref')
|
||||
}
|
||||
}, [message.blocks])
|
||||
// One pass per block set, shared with the list that decides whether this row
|
||||
// occupies a slot — so "draws nothing" means the same thing to both.
|
||||
const { hasImages, markdown, prose, subagentGroups, tools } = deriveNativeChatRowContent(
|
||||
message.blocks
|
||||
)
|
||||
const isUser = message.role === 'user'
|
||||
const isReasoning = message.role === 'reasoning'
|
||||
const isSystem = message.role === 'system'
|
||||
@@ -220,6 +193,7 @@ export const MessageRow = memo(function MessageRow({
|
||||
expandOverride={activityExpandOverride}
|
||||
activeTurnIsWorking={activeTurnIsWorking}
|
||||
structuredActivityUi={structuredActivityUi}
|
||||
disclosureId={message.id}
|
||||
/>
|
||||
) : null}
|
||||
{showControls ? (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
NativeChatCommandMetadata,
|
||||
NativeChatSearchResults,
|
||||
NativeChatToolName
|
||||
} from './NativeChatToolAnnotations'
|
||||
import { NativeChatToolIcon } from './NativeChatToolIcon'
|
||||
import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { createToolInputDisplay, truncateToolDetail } from './native-chat-tool-summary'
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Tool calls read as flat
|
||||
* lines in the conversation rather than boxed blocks (mobile parity). Lines only
|
||||
* mount while the parent run is open and are individually collapsible. */
|
||||
export function NativeChatToolLine({
|
||||
block,
|
||||
initiallyExpanded = true,
|
||||
disclosureKey,
|
||||
onLinkClick
|
||||
}: {
|
||||
block: NativeChatBlock
|
||||
initiallyExpanded?: boolean
|
||||
/** Identity this line's open state is remembered under while it is unmounted. */
|
||||
disclosureKey?: string
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element | null {
|
||||
const { open: expanded, setOpen: setExpanded } = useNativeChatDisclosure(
|
||||
disclosureKey,
|
||||
initiallyExpanded
|
||||
)
|
||||
|
||||
let name: string
|
||||
let preview: string
|
||||
let diff: DiffLine[] | null = null
|
||||
let body: { output: string; isError?: boolean } | null = null
|
||||
let detail: string | null = null
|
||||
let inputHasDetail = false
|
||||
const isCall = isToolCallBlock(block)
|
||||
|
||||
if (isCall) {
|
||||
name = block.name
|
||||
const inputDisplay = createToolInputDisplay(block.input)
|
||||
preview = inputDisplay.label
|
||||
inputHasDetail = inputDisplay.hasDetail
|
||||
diff = expanded ? diffFromToolCall(block.name, block.input) : null
|
||||
detail = expanded && !diff ? inputDisplay.formatDetail() : null
|
||||
} else if (isToolResultBlock(block)) {
|
||||
name = translate('components.native-chat.tool.result', 'Result')
|
||||
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
diff = expanded ? diffFromText(block.output) : null
|
||||
body = { output: block.output, isError: block.isError }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasResults = isCall && (block.webSearchResults?.length ?? 0) > 0
|
||||
const hasDetail = diff !== null || body !== null || inputHasDetail || hasResults
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 py-0.5 text-left',
|
||||
hasDetail ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
aria-expanded={hasDetail ? expanded : undefined}
|
||||
>
|
||||
{isCall ? (
|
||||
/* Decorative category glyph; the word beside it is the row's name. */
|
||||
<NativeChatToolIcon
|
||||
mcpIdentity={block.mcpIdentity}
|
||||
rowWord={name}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
/* A result's word is translated copy, not a tool name, so there is no
|
||||
category to read from it. The empty slot keeps rows aligned. */
|
||||
<span aria-hidden className="size-4 shrink-0" />
|
||||
)}
|
||||
<code className="min-w-0 truncate font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
|
||||
{isCall ? <NativeChatToolName name={name} mcpIdentity={block.mcpIdentity} /> : name}
|
||||
</code>
|
||||
{preview ? (
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/70"
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
{isCall ? <NativeChatCommandMetadata block={block} /> : null}
|
||||
{hasDetail ? (
|
||||
// Chevron stays hidden until this row is expanded.
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
{hasDetail && expanded ? (
|
||||
<div className="space-y-1.5 py-1">
|
||||
{isCall && hasResults ? (
|
||||
<NativeChatSearchResults results={block.webSearchResults} onLinkClick={onLinkClick} />
|
||||
) : null}
|
||||
{diff ? <NativeChatDiffView lines={diff} /> : null}
|
||||
{!diff && body ? (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] scrollbar-sleek',
|
||||
body.isError ? 'text-destructive' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{truncateToolDetail(body.output)}
|
||||
</pre>
|
||||
) : null}
|
||||
{!diff && !body && detail ? (
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
|
||||
{detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,24 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
NativeChatDisclosureContext,
|
||||
useNativeChatDisclosures
|
||||
} from './native-chat-disclosure-store'
|
||||
|
||||
vi.mock('./NativeChatDiffCard', () => ({ NativeChatDiffCard: () => null }))
|
||||
vi.mock('./NativeChatDiffView', () => ({ NativeChatDiffView: () => null }))
|
||||
afterEach(cleanup)
|
||||
|
||||
const disclosureWrite = vi.fn()
|
||||
const capturedDisclosures = {
|
||||
read: (_key: string) => undefined,
|
||||
write: (key: string, open: boolean) => disclosureWrite(key, open)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
disclosureWrite.mockReset()
|
||||
})
|
||||
|
||||
const shell: NativeChatToolCallBlock = {
|
||||
type: 'tool-call',
|
||||
@@ -17,7 +31,100 @@ const shell: NativeChatToolCallBlock = {
|
||||
durationMs: 400
|
||||
}
|
||||
|
||||
function ToolRunDisclosureHarness({ expandOverride }: { expandOverride: boolean }) {
|
||||
const disclosures = useNativeChatDisclosures()
|
||||
return (
|
||||
<NativeChatDisclosureContext.Provider value={disclosures}>
|
||||
<NativeChatToolRun
|
||||
blocks={[shell]}
|
||||
expandSignal={false}
|
||||
expandOverride={expandOverride}
|
||||
activeTurnIsWorking={false}
|
||||
disclosureId="message-1"
|
||||
/>
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('inline tool annotations', () => {
|
||||
it('restores a per-run deviation when its turn returns to the same disclosure state', () => {
|
||||
const { rerender } = render(<ToolRunDisclosureHarness expandOverride />)
|
||||
const run = screen.getByRole('button', { expanded: true })
|
||||
|
||||
fireEvent.click(run)
|
||||
expect(run.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
rerender(<ToolRunDisclosureHarness expandOverride={false} />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
|
||||
rerender(<ToolRunDisclosureHarness expandOverride />)
|
||||
expect(screen.getByRole('button', { name: /1×/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('resynchronizes a standalone run when the toolbar signal flips', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatToolRun blocks={[shell]} expandSignal={false} activeTurnIsWorking={false} />
|
||||
)
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
rerender(<NativeChatToolRun blocks={[shell]} expandSignal activeTurnIsWorking={false} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /1×/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('uses provider call identities for byte-identical line disclosure keys', () => {
|
||||
const blocks = [
|
||||
{ ...shell, callId: 'call-a' },
|
||||
{ ...shell, callId: 'call-b' }
|
||||
]
|
||||
render(
|
||||
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
|
||||
<NativeChatToolRun blocks={blocks} expandSignal disclosureId="message-1" />
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2]!)
|
||||
|
||||
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith('line:message-1:call:call-b', false)
|
||||
})
|
||||
|
||||
it('keeps occurrence identity as the fallback for calls without provider IDs', () => {
|
||||
render(
|
||||
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
|
||||
<NativeChatToolRun blocks={[shell, shell]} expandSignal disclosureId="message-1" />
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2]!)
|
||||
|
||||
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith(
|
||||
'line:message-1:tool-call:shell:{"command":"missing-command"}:1',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps occurrence identity for whitespace-only provider IDs', () => {
|
||||
render(
|
||||
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
|
||||
<NativeChatToolRun
|
||||
blocks={[
|
||||
{ ...shell, callId: ' ' },
|
||||
{ ...shell, callId: '\t' }
|
||||
]}
|
||||
expandSignal
|
||||
disclosureId="message-1"
|
||||
/>
|
||||
</NativeChatDisclosureContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2]!)
|
||||
|
||||
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith(
|
||||
'line:message-1:tool-call:shell:{"command":"missing-command"}:1',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps command completion annotations on the collapsed tool line', () => {
|
||||
render(
|
||||
<NativeChatToolRun
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import {
|
||||
NativeChatToolName,
|
||||
NativeChatCommandMetadata,
|
||||
NativeChatSearchResults
|
||||
} from './NativeChatToolAnnotations'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { NativeChatToolLine } from './NativeChatToolLine'
|
||||
import { Check, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock,
|
||||
type NativeChatSubagentGroupBlock,
|
||||
type NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import { buildEditCards, NO_EDIT_CARDS } from './native-chat-edit-cards'
|
||||
import {
|
||||
countToolCalls,
|
||||
createToolInputDisplay,
|
||||
toolRunSummaryMembers,
|
||||
truncateToolDetail,
|
||||
type ToolRunMember
|
||||
} from './native-chat-tool-summary'
|
||||
import {
|
||||
@@ -34,7 +27,6 @@ import {
|
||||
import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon'
|
||||
import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { buildNativeChatTaskListRows } from './native-chat-task-list-history'
|
||||
import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
|
||||
import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label'
|
||||
@@ -42,123 +34,8 @@ import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label'
|
||||
/** Stable empty default: a fresh array literal per render breaks memoization. */
|
||||
const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = []
|
||||
|
||||
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
|
||||
* show the call's diff/input or the result's body. Tool calls read as flat
|
||||
* lines in the conversation rather than boxed blocks (mobile parity). Lines only
|
||||
* mount while the parent run is open and are individually collapsible. */
|
||||
function ToolLine({
|
||||
block,
|
||||
initiallyExpanded = true,
|
||||
onLinkClick
|
||||
}: {
|
||||
block: NativeChatBlock
|
||||
initiallyExpanded?: boolean
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element | null {
|
||||
const [expanded, setExpanded] = useState(initiallyExpanded)
|
||||
|
||||
let name: string
|
||||
let preview: string
|
||||
let diff: DiffLine[] | null = null
|
||||
let body: { output: string; isError?: boolean } | null = null
|
||||
let detail: string | null = null
|
||||
let inputHasDetail = false
|
||||
const isCall = isToolCallBlock(block)
|
||||
|
||||
if (isCall) {
|
||||
name = block.name
|
||||
const inputDisplay = createToolInputDisplay(block.input)
|
||||
preview = inputDisplay.label
|
||||
inputHasDetail = inputDisplay.hasDetail
|
||||
diff = expanded ? diffFromToolCall(block.name, block.input) : null
|
||||
detail = expanded && !diff ? inputDisplay.formatDetail() : null
|
||||
} else if (isToolResultBlock(block)) {
|
||||
name = translate('components.native-chat.tool.result', 'Result')
|
||||
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
diff = expanded ? diffFromText(block.output) : null
|
||||
body = { output: block.output, isError: block.isError }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasResults = isCall && (block.webSearchResults?.length ?? 0) > 0
|
||||
const hasDetail = diff !== null || body !== null || inputHasDetail || hasResults
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasDetail && setExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-1.5 py-0.5 text-left',
|
||||
hasDetail ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
aria-expanded={hasDetail ? expanded : undefined}
|
||||
>
|
||||
{isCall ? (
|
||||
/* Decorative category glyph; the word beside it is the row's name. */
|
||||
<NativeChatToolIcon
|
||||
mcpIdentity={block.mcpIdentity}
|
||||
rowWord={name}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
/* A result's word is translated copy, not a tool name, so there is no
|
||||
category to read from it. The empty slot keeps rows aligned. */
|
||||
<span aria-hidden className="size-4 shrink-0" />
|
||||
)}
|
||||
<code className="min-w-0 truncate font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
|
||||
{isCall ? <NativeChatToolName name={name} mcpIdentity={block.mcpIdentity} /> : name}
|
||||
</code>
|
||||
{preview ? (
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/70"
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
{isCall ? <NativeChatCommandMetadata block={block} /> : null}
|
||||
{hasDetail ? (
|
||||
// Chevron stays hidden until this row is expanded.
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
{hasDetail && expanded ? (
|
||||
<div className="space-y-1.5 py-1">
|
||||
{isCall && hasResults ? (
|
||||
<NativeChatSearchResults results={block.webSearchResults} onLinkClick={onLinkClick} />
|
||||
) : null}
|
||||
{diff ? <NativeChatDiffView lines={diff} /> : null}
|
||||
{!diff && body ? (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] scrollbar-sleek',
|
||||
body.isError ? 'text-destructive' : 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{truncateToolDetail(body.output)}
|
||||
</pre>
|
||||
) : null}
|
||||
{!diff && !body && detail ? (
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
|
||||
{detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `expandSignal` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
* expands to the individual inline tool lines. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
previousTodoWrite,
|
||||
@@ -170,6 +47,7 @@ export function NativeChatToolRun({
|
||||
activeTurnIsWorking,
|
||||
expandOverride,
|
||||
structuredActivityUi = true,
|
||||
disclosureId,
|
||||
onLinkClick
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
@@ -179,32 +57,28 @@ export function NativeChatToolRun({
|
||||
onRevealDiff?: (element: HTMLElement) => void
|
||||
/** Spawn-group rosters that belong with this run's activity, one row each. */
|
||||
subagentGroups?: NativeChatSubagentGroupBlock[]
|
||||
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
|
||||
/** Legacy view-level default; production native-chat entry points pass false. */
|
||||
expandSignal: boolean
|
||||
/** Per-turn disclosure state controlled by the completed turn status row. */
|
||||
expandOverride?: boolean
|
||||
/** Structured lifecycle state, when available, keeps orphaned running calls from spinning. */
|
||||
activeTurnIsWorking?: boolean
|
||||
structuredActivityUi?: boolean
|
||||
/** Message this run belongs to. Windowing unmounts rows, so a run the reader
|
||||
* opened has to be remembered somewhere that outlives the row. */
|
||||
disclosureId?: string
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element | null {
|
||||
const [open, setOpen] = useState(revealedDiff ? true : (expandOverride ?? expandSignal))
|
||||
const [controls, setControls] = useState({ expandOverride, expandSignal, revealedDiff })
|
||||
if (
|
||||
controls.expandOverride !== expandOverride ||
|
||||
controls.expandSignal !== expandSignal ||
|
||||
controls.revealedDiff !== revealedDiff
|
||||
) {
|
||||
setControls({ expandOverride, expandSignal, revealedDiff })
|
||||
if (revealedDiff && controls.revealedDiff !== revealedDiff) {
|
||||
setOpen(true)
|
||||
} else if (
|
||||
controls.expandOverride !== expandOverride ||
|
||||
controls.expandSignal !== expandSignal
|
||||
) {
|
||||
setOpen(expandOverride ?? expandSignal)
|
||||
}
|
||||
}
|
||||
// A reader's deviation belongs to the controlling disclosure state, so returning
|
||||
// to that state restores the same choice without writing to the store mid-render.
|
||||
const runKey =
|
||||
disclosureId === undefined
|
||||
? undefined
|
||||
: `run:${disclosureId}:${expandOverride ?? '-'}:${expandSignal}:${revealedDiff?.requestId ?? '-'}`
|
||||
const { open, setOpen } = useNativeChatDisclosure(
|
||||
runKey,
|
||||
revealedDiff ? true : (expandOverride ?? expandSignal)
|
||||
)
|
||||
|
||||
// Childless groups are dropped so `subagentRows.length` stays an honest test of
|
||||
// "something will draw": the roster-only branch below returns a margin-bearing
|
||||
@@ -236,8 +110,7 @@ export function NativeChatToolRun({
|
||||
: null
|
||||
const isSettled = latestActiveCall == null
|
||||
const hasRunningCall = blocks.some((block) => isToolCallBlock(block) && block.state === 'running')
|
||||
// The turn caret opens the activity group, while each child tool remains
|
||||
// collapsed. The global expand toolbar still opens child details together.
|
||||
// The turn caret opens the activity group while each child tool stays collapsed.
|
||||
const expandToolLines = expandOverride === undefined ? open : false
|
||||
// Diffing every edit is the run's most expensive work, so a collapsed run —
|
||||
// which renders none of it — never pays for it.
|
||||
@@ -307,7 +180,7 @@ export function NativeChatToolRun({
|
||||
{latestActiveCall ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
@@ -325,7 +198,7 @@ export function NativeChatToolRun({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -424,6 +297,11 @@ export function NativeChatToolRun({
|
||||
}
|
||||
onReveal={onRevealDiff}
|
||||
initiallyExpanded={expandToolLines}
|
||||
disclosureKey={
|
||||
disclosureId === undefined
|
||||
? undefined
|
||||
: `diff:${disclosureId}:${edit.key}:${fileIndex}`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -440,12 +318,25 @@ export function NativeChatToolRun({
|
||||
: `${block.type}`
|
||||
const occurrence = seen.get(signature) ?? 0
|
||||
seen.set(signature, occurrence + 1)
|
||||
const providerCallId =
|
||||
block.type === 'tool-call' &&
|
||||
block.callId !== undefined &&
|
||||
block.callId.trim().length > 0
|
||||
? block.callId
|
||||
: undefined
|
||||
const lineIdentity =
|
||||
providerCallId !== undefined
|
||||
? `call:${providerCallId}`
|
||||
: `${signature}:${occurrence}`
|
||||
return (
|
||||
<ToolLine
|
||||
key={`${signature}:${occurrence}`}
|
||||
<NativeChatToolLine
|
||||
key={lineIdentity}
|
||||
block={block}
|
||||
onLinkClick={onLinkClick}
|
||||
initiallyExpanded={expandToolLines}
|
||||
disclosureKey={
|
||||
disclosureId === undefined ? undefined : `line:${disclosureId}:${lineIdentity}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
NativeChatTranscriptRow,
|
||||
type NativeChatTranscriptRowContext
|
||||
} from './NativeChatTranscriptRow'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window'
|
||||
|
||||
/** Windowed transcript rows, absolutely positioned inside a full-height spacer. */
|
||||
export function NativeChatTranscriptItems({
|
||||
slots,
|
||||
context,
|
||||
window
|
||||
}: {
|
||||
slots: readonly NativeChatTranscriptSlot[]
|
||||
context: NativeChatTranscriptRowContext
|
||||
window: NativeChatTranscriptWindow
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={window.sizerRef}
|
||||
data-native-chat-window
|
||||
className="relative w-full"
|
||||
style={{ height: `${window.totalSize}px` }}
|
||||
>
|
||||
{window.virtualItems.map((item) => {
|
||||
const slot = slots[item.index]
|
||||
if (!slot) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={window.measureRow}
|
||||
// `top`, not a transform: the reveal path walks `offsetTop` to find
|
||||
// where a card sits, and a transform is invisible to it.
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${item.start - window.scrollMargin}px`,
|
||||
left: 0,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<NativeChatTranscriptRow slot={slot} context={context} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import { MessageRow } from './NativeChatMessageRow'
|
||||
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
|
||||
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
|
||||
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
|
||||
import type { NativeChatTaskListPredecessors } from './native-chat-task-list-history'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs'
|
||||
|
||||
/** Everything a row needs that is the same for every row. Held as one memoized
|
||||
* object so a row's props change only when that row's own slot does. */
|
||||
export type NativeChatTranscriptRowContext = {
|
||||
expandSignal: boolean
|
||||
showTurnStatus: boolean
|
||||
revealedDiff: NativeChatDiffReveal | null
|
||||
taskListPredecessors: ReadonlyMap<string, NativeChatTaskListPredecessors>
|
||||
expandedTurnIds: ReadonlySet<string>
|
||||
failedDeliveryMessageIds?: ReadonlySet<string>
|
||||
allowFileUriLinks: boolean
|
||||
runtimeContext?: RuntimeFileOperationArgs | null
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
onToggleExpandedTurn: (turnKey: string) => void
|
||||
onScrollMessageToTop: (element: HTMLElement) => void
|
||||
onRevealDiff: (target: NativeChatDiffTarget) => void
|
||||
}
|
||||
|
||||
/** One transcript row: the message (or the receipt standing in for it), the turn
|
||||
* status under it, and the turn's diff rollup.
|
||||
*
|
||||
* These three were siblings in the transcript column and took their spacing from
|
||||
* it. Windowing needs one element per row to position and measure, so the
|
||||
* wrapper carries that spacing itself — the gap BETWEEN rows is the window's. */
|
||||
export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
slot,
|
||||
context
|
||||
}: {
|
||||
slot: NativeChatTranscriptSlot
|
||||
context: NativeChatTranscriptRowContext
|
||||
}): React.JSX.Element {
|
||||
const { message, turnKey, status, receipt, turnDiff } = slot
|
||||
const predecessors = context.taskListPredecessors.get(message.id)
|
||||
const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{receipt ? (
|
||||
<NativeChatResolutionReceipt body={receipt} />
|
||||
) : (
|
||||
<MessageRow
|
||||
message={message}
|
||||
previousTodoWrite={predecessors?.todowrite}
|
||||
previousUpdatePlan={predecessors?.update_plan}
|
||||
revealedDiff={
|
||||
context.revealedDiff?.messageId === message.id ? context.revealedDiff : undefined
|
||||
}
|
||||
expandSignal={context.expandSignal}
|
||||
activeTurnIsWorking={slot.activeTurnIsWorking}
|
||||
onScrollMessageToTop={context.onScrollMessageToTop}
|
||||
onLinkClick={context.onLinkClick}
|
||||
allowFileUriLinks={context.allowFileUriLinks}
|
||||
deliveryFailed={context.failedDeliveryMessageIds?.has(message.id) === true}
|
||||
structuredActivityUi={context.showTurnStatus}
|
||||
activityExpandOverride={expanded}
|
||||
runtimeContext={context.runtimeContext}
|
||||
/>
|
||||
)}
|
||||
{status ? (
|
||||
<NativeChatWorkingStatus
|
||||
startedAt={status.startedAt}
|
||||
thinking={status.thinking}
|
||||
workedSeconds={status.workedSeconds}
|
||||
expanded={expanded === true}
|
||||
onToggleExpanded={
|
||||
status.workedSeconds != null && turnKey
|
||||
? () => context.onToggleExpandedTurn(turnKey)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{turnDiff ? (
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
distanceFromBottom,
|
||||
isNearBottom,
|
||||
shouldLoadEarlier,
|
||||
shouldShowJumpToLatest,
|
||||
NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
} from './native-chat-autoscroll'
|
||||
@@ -42,3 +43,47 @@ describe('shouldShowJumpToLatest', () => {
|
||||
expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Windowing turns measurement into a constant source of movement: every row that
|
||||
// resolves its real height changes the content and re-fires the observers that
|
||||
// ask this question. So "near the top" alone can no longer be the answer.
|
||||
describe('shouldLoadEarlier', () => {
|
||||
const nearTop = { scrollTop: 10, scrollHeight: 4000, clientHeight: 600 }
|
||||
const base = {
|
||||
geometry: nearTop,
|
||||
previousScrollTop: 400,
|
||||
hasMore: true,
|
||||
loadingEarlier: false,
|
||||
itemCount: 40,
|
||||
requestedAtItemCount: null
|
||||
}
|
||||
|
||||
it('pages in older history when the reader scrolls up to the top', () => {
|
||||
expect(shouldLoadEarlier(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('says nothing to page when there is no more history', () => {
|
||||
expect(shouldLoadEarlier({ ...base, hasMore: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for the page already in flight', () => {
|
||||
expect(shouldLoadEarlier({ ...base, loadingEarlier: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a position that is not near the top', () => {
|
||||
expect(shouldLoadEarlier({ ...base, geometry: { ...nearTop, scrollTop: 400 } })).toBe(false)
|
||||
})
|
||||
|
||||
// The bottom pin and a settling measurement both move the view DOWN. Only a
|
||||
// reader moving up is asking for older history.
|
||||
it('ignores movement towards the bottom', () => {
|
||||
expect(shouldLoadEarlier({ ...base, previousScrollTop: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('asks once per page, not once per measurement, while parked at the top', () => {
|
||||
const parked = { ...base, previousScrollTop: 10, requestedAtItemCount: 40 }
|
||||
expect(shouldLoadEarlier(parked)).toBe(false)
|
||||
// New history arrived and the reader is still at the top: asking again is right.
|
||||
expect(shouldLoadEarlier({ ...parked, itemCount: 60 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,3 +42,38 @@ export function shouldShowJumpToLatest(
|
||||
}
|
||||
return distanceFromBottom(geometry) > threshold
|
||||
}
|
||||
|
||||
/** Distance from the top within which the transcript pages in older history. */
|
||||
export const NATIVE_CHAT_LOAD_EARLIER_THRESHOLD_PX = 80
|
||||
|
||||
export type LoadEarlierIntent = {
|
||||
geometry: ScrollGeometry
|
||||
/** Offset at the previous scroll event; a prepend or a bottom pin moves down. */
|
||||
previousScrollTop: number
|
||||
hasMore: boolean
|
||||
loadingEarlier: boolean
|
||||
itemCount: number
|
||||
/** Item count when the last page was asked for, or null if none has been. */
|
||||
requestedAtItemCount: number | null
|
||||
}
|
||||
|
||||
/** Whether reaching this offset should page in older history.
|
||||
*
|
||||
* Windowing makes the naive "am I near the top?" test unsafe: every row that
|
||||
* resolves its real height changes the content height and re-fires the
|
||||
* observers that ask. So the answer also requires the view to have moved
|
||||
* *upwards* — measurement settling and the bottom pin both move it down — and
|
||||
* requires new items since the last request, which caps a stuck near-top view at
|
||||
* one request per page rather than one per measurement. */
|
||||
export function shouldLoadEarlier(intent: LoadEarlierIntent): boolean {
|
||||
if (!intent.hasMore || intent.loadingEarlier) {
|
||||
return false
|
||||
}
|
||||
if (intent.geometry.scrollTop >= NATIVE_CHAT_LOAD_EARLIER_THRESHOLD_PX) {
|
||||
return false
|
||||
}
|
||||
if (intent.geometry.scrollTop > intent.previousScrollTop) {
|
||||
return false
|
||||
}
|
||||
return intent.requestedAtItemCount !== intent.itemCount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Where a transcript row's open/closed disclosures live when the row itself may
|
||||
// be unmounted.
|
||||
//
|
||||
// A tool run the reader opened is state they created. Held in the run's own
|
||||
// `useState` it survives exactly as long as the row is mounted, which under
|
||||
// windowing is "until you scroll past it" — the run silently re-collapses behind
|
||||
// the reader's back. Rows read through this store when the transcript provides
|
||||
// one, and fall back to their own state when they are rendered standalone.
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
|
||||
export type NativeChatDisclosureStore = {
|
||||
read: (key: string) => boolean | undefined
|
||||
write: (key: string, open: boolean) => void
|
||||
}
|
||||
|
||||
export const NativeChatDisclosureContext = createContext<NativeChatDisclosureStore | null>(null)
|
||||
|
||||
/** Bounded like the transcript's other per-turn map: a session that ran for a day
|
||||
* should not carry every disclosure it ever opened. */
|
||||
export const MAX_NATIVE_CHAT_DISCLOSURES = 512
|
||||
|
||||
export function useNativeChatDisclosures(): NativeChatDisclosureStore {
|
||||
const [open, setOpen] = useState<ReadonlyMap<string, boolean>>(() => new Map())
|
||||
const write = useCallback((key: string, next: boolean) => {
|
||||
setOpen((current) => {
|
||||
if (current.get(key) === next) {
|
||||
return current
|
||||
}
|
||||
const updated = new Map(current)
|
||||
updated.set(key, next)
|
||||
if (updated.size > MAX_NATIVE_CHAT_DISCLOSURES) {
|
||||
const oldest = updated.keys().next().value
|
||||
if (oldest !== undefined && oldest !== key) {
|
||||
updated.delete(oldest)
|
||||
}
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
return useMemo(() => ({ read: (key: string) => open.get(key), write }), [open, write])
|
||||
}
|
||||
|
||||
export type NativeChatDisclosure = {
|
||||
open: boolean
|
||||
/** A reader's choice: remembered past this row's lifetime when keyed. */
|
||||
setOpen: (next: boolean) => void
|
||||
}
|
||||
|
||||
export function useNativeChatDisclosure(
|
||||
key: string | undefined,
|
||||
initialOpen: boolean
|
||||
): NativeChatDisclosure {
|
||||
const store = useContext(NativeChatDisclosureContext)
|
||||
const [local, setLocal] = useState({ key, initialOpen, open: initialOpen })
|
||||
const write = store?.write
|
||||
const isStored = key !== undefined && store !== null
|
||||
const localOpen =
|
||||
local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen
|
||||
const open = isStored ? (store.read(key) ?? localOpen) : localOpen
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
setLocal({ key, initialOpen, open: next })
|
||||
if (key !== undefined && write) {
|
||||
write(key, next)
|
||||
}
|
||||
},
|
||||
[initialOpen, key, write]
|
||||
)
|
||||
return { open, setOpen }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const TEST_VIEWPORT_HEIGHT_PX = 1_000_000
|
||||
const TEST_ROW_HEIGHT_PX = 48
|
||||
|
||||
/** Give non-windowing component tests a measurable viewport that contains every fixture row. */
|
||||
export function installNativeChatMessageListTestViewport(): () => void {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement): number {
|
||||
if (this.hasAttribute('data-native-chat-scroll')) {
|
||||
return TEST_VIEWPORT_HEIGHT_PX
|
||||
}
|
||||
if (this.dataset.index !== undefined) {
|
||||
return TEST_ROW_HEIGHT_PX
|
||||
}
|
||||
return original?.get?.call(this) ?? 0
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', original)
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'offsetHeight')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows'
|
||||
|
||||
describe('pinned transcript rows', () => {
|
||||
it('pins the live row at the end of the transcript', () => {
|
||||
expect([...nativeChatPinnedRowIndexes({ count: 40 })]).toEqual([39])
|
||||
})
|
||||
|
||||
it('pins nothing when there is nothing to pin', () => {
|
||||
expect(nativeChatPinnedRowIndexes({ count: 0 }).size).toBe(0)
|
||||
})
|
||||
|
||||
it('pins a row a reveal is aimed at, wherever it sits', () => {
|
||||
expect([...nativeChatPinnedRowIndexes({ count: 40, revealIndex: 3 })].sort((a, b) => a - b)) //
|
||||
.toEqual([3, 39])
|
||||
})
|
||||
|
||||
it('ignores a reveal target that is no longer in the transcript', () => {
|
||||
expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: -1 })]).toEqual([4])
|
||||
expect([...nativeChatPinnedRowIndexes({ count: 5, revealIndex: 99 })]).toEqual([4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcript window range', () => {
|
||||
const range = { startIndex: 10, endIndex: 12, overscan: 2, count: 100 }
|
||||
|
||||
it('widens the window by overscan on both sides', () => {
|
||||
expect(nativeChatTranscriptRange(range, new Set())).toEqual([8, 9, 10, 11, 12, 13, 14])
|
||||
})
|
||||
|
||||
it('clamps to the transcript at both ends', () => {
|
||||
expect(
|
||||
nativeChatTranscriptRange({ startIndex: 0, endIndex: 1, overscan: 3, count: 3 }, new Set())
|
||||
).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
it('adds pinned rows outside the window, in transcript order and without repeats', () => {
|
||||
expect(nativeChatTranscriptRange(range, new Set([99, 11, 0]))).toEqual([
|
||||
0, 8, 9, 10, 11, 12, 13, 14, 99
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a pinned row that is out of bounds rather than mounting nothing at it', () => {
|
||||
expect(nativeChatTranscriptRange(range, new Set([100, -1]))).toEqual([8, 9, 10, 11, 12, 13, 14])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// Rows the transcript keeps mounted no matter where the window sits, and the
|
||||
// range extraction that folds them into the virtualizer's own range.
|
||||
|
||||
/** The virtualizer's range, restated so this module needs nothing from the lib. */
|
||||
export type NativeChatWindowRange = {
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
overscan: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export type NativeChatPinnedRowsInput = {
|
||||
count: number
|
||||
/** Row holding a diff the transcript was asked to reveal; it has to be mounted
|
||||
* for its card to expand and report where to scroll. */
|
||||
revealIndex?: number | null
|
||||
}
|
||||
|
||||
/** Indexes that stay mounted outside the window.
|
||||
*
|
||||
* The last row is pinned because it is the live one: a running tool announces
|
||||
* itself through `aria-live`, which says nothing from a row that isn't in the
|
||||
* document, and its measured height is what keeps the bottom pin honest while
|
||||
* a turn streams. */
|
||||
export function nativeChatPinnedRowIndexes({
|
||||
count,
|
||||
revealIndex
|
||||
}: NativeChatPinnedRowsInput): ReadonlySet<number> {
|
||||
const pinned = new Set<number>()
|
||||
if (count <= 0) {
|
||||
return pinned
|
||||
}
|
||||
pinned.add(count - 1)
|
||||
if (revealIndex != null && revealIndex >= 0 && revealIndex < count) {
|
||||
pinned.add(revealIndex)
|
||||
}
|
||||
return pinned
|
||||
}
|
||||
|
||||
/** The window's own range widened by overscan, plus the pinned rows. */
|
||||
export function nativeChatTranscriptRange(
|
||||
range: NativeChatWindowRange,
|
||||
pinned: ReadonlySet<number>
|
||||
): number[] {
|
||||
const first = Math.max(range.startIndex - range.overscan, 0)
|
||||
const last = Math.min(range.endIndex + range.overscan, range.count - 1)
|
||||
const indexes = new Set<number>()
|
||||
for (let index = first; index <= last; index += 1) {
|
||||
indexes.add(index)
|
||||
}
|
||||
for (const index of pinned) {
|
||||
if (index >= 0 && index < range.count) {
|
||||
indexes.add(index)
|
||||
}
|
||||
}
|
||||
return Array.from(indexes).sort((left, right) => left - right)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// One derivation of a message's renderable parts, shared by the row that draws it
|
||||
// and the list that decides whether it occupies a transcript slot. Windowing makes
|
||||
// that agreement load-bearing: a row the list counts but the row component declines
|
||||
// to draw would reserve estimated height for nothing.
|
||||
//
|
||||
// Cached on the block array itself, so a streaming turn re-deriving on every frame
|
||||
// pays once per revision rather than once per consumer.
|
||||
|
||||
import {
|
||||
isSubagentGroupFallbackText,
|
||||
subagentGroupBlocks
|
||||
} from '../../../../shared/native-chat-subagent-summary'
|
||||
import { isSubagentGroupBlock, type NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import { splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
import { nativeChatProseToMarkdown } from './native-chat-prose'
|
||||
|
||||
/** Inferred from `derive` so the shape cannot drift from what it returns. */
|
||||
export type NativeChatRowContent = ReturnType<typeof derive>
|
||||
|
||||
const derivations = new WeakMap<object, NativeChatRowContent>()
|
||||
|
||||
function derive(blocks: readonly NativeChatBlock[]) {
|
||||
const split = splitNativeChatBlocks(blocks)
|
||||
const groups = subagentGroupBlocks(split.prose)
|
||||
// A spawn-group row carries a plain-text twin so a client without the block type
|
||||
// still reads the roster. This draws the block, so only the twin is dropped —
|
||||
// never real text beside it, which a lane folding a roster into a message keeps.
|
||||
const prose =
|
||||
groups.length === 0
|
||||
? split.prose
|
||||
: split.prose.filter(
|
||||
(block) =>
|
||||
!isSubagentGroupBlock(block) &&
|
||||
!(block.type === 'text' && isSubagentGroupFallbackText(block.text))
|
||||
)
|
||||
return {
|
||||
prose,
|
||||
tools: split.tools,
|
||||
subagentGroups: groups,
|
||||
markdown: nativeChatProseToMarkdown(prose),
|
||||
hasImages: prose.some((block) => block.type === 'image-ref')
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveNativeChatRowContent(
|
||||
blocks: readonly NativeChatBlock[]
|
||||
): NativeChatRowContent {
|
||||
const cached = derivations.get(blocks)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const content = derive(blocks)
|
||||
derivations.set(blocks, content)
|
||||
return content
|
||||
}
|
||||
|
||||
/** Whether the row draws anything. An empty row takes no slot in the transcript. */
|
||||
export function nativeChatRowRendersContent(blocks: readonly NativeChatBlock[]): boolean {
|
||||
const { markdown, hasImages, tools, subagentGroups } = deriveNativeChatRowContent(blocks)
|
||||
return markdown.length > 0 || hasImages || tools.length > 0 || subagentGroups.length > 0
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
estimateNativeChatRowHeight,
|
||||
estimateNativeChatTextLines,
|
||||
NATIVE_CHAT_ROW_GAP_PX,
|
||||
nativeChatRowContentMetrics
|
||||
} from './native-chat-row-height-estimate'
|
||||
|
||||
const NO_CHROME = { hasReceipt: false, hasStatus: false, hasTurnDiff: false }
|
||||
|
||||
function message(text: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
|
||||
return {
|
||||
id: `m-${text.length}`,
|
||||
role,
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('transcript row height estimate', () => {
|
||||
it('counts hard breaks and soft wraps as separate display lines', () => {
|
||||
expect(estimateNativeChatTextLines('')).toBe(0)
|
||||
expect(estimateNativeChatTextLines('one line')).toBe(1)
|
||||
expect(estimateNativeChatTextLines('one\ntwo\nthree')).toBe(3)
|
||||
// A single 300-character paragraph wraps rather than staying one line.
|
||||
expect(estimateNativeChatTextLines('x'.repeat(300))).toBeGreaterThan(1)
|
||||
// A blank line still occupies one.
|
||||
expect(estimateNativeChatTextLines('a\n\nb')).toBe(3)
|
||||
})
|
||||
|
||||
it('grows with the prose it is estimating', () => {
|
||||
const short = estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics(message('one line')),
|
||||
NO_CHROME
|
||||
)
|
||||
const long = estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics(message(Array.from({ length: 40 }, () => 'line').join('\n'))),
|
||||
NO_CHROME
|
||||
)
|
||||
expect(long).toBeGreaterThan(short)
|
||||
})
|
||||
|
||||
it('bounds the estimate at both ends', () => {
|
||||
const empty = estimateNativeChatRowHeight(nativeChatRowContentMetrics(message('')), NO_CHROME)
|
||||
const enormous = estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics(message('line\n'.repeat(5000))),
|
||||
NO_CHROME
|
||||
)
|
||||
expect(empty).toBeGreaterThan(0)
|
||||
expect(enormous).toBeLessThan(5000 * 22)
|
||||
})
|
||||
|
||||
// The gap between rows belongs to the window, which puts one between every
|
||||
// pair. A row that also charged for it would sit one gap lower than the row
|
||||
// above it, and the drift compounds the whole way down the transcript.
|
||||
//
|
||||
// Stated without naming any constant: a prose row is its lines and nothing
|
||||
// else, so its estimate has to be exactly its line count times what one more
|
||||
// line costs. Any fixed amount riding along — a gap above all — breaks that.
|
||||
it('charges a prose row for its lines and for nothing else', () => {
|
||||
const lines = (count: number): number =>
|
||||
estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics(message(Array.from({ length: count }, () => 'x').join('\n'))),
|
||||
NO_CHROME
|
||||
)
|
||||
const perLine = lines(10) - lines(9)
|
||||
|
||||
expect(perLine).toBeGreaterThan(0)
|
||||
expect(lines(10)).toBe(10 * perLine)
|
||||
})
|
||||
|
||||
// The parts stacked INSIDE one row do pay for the gap between them, because
|
||||
// that gap is inside the height the row will be measured at.
|
||||
it('charges a row for the gap above a turn status it carries', () => {
|
||||
const metrics = nativeChatRowContentMetrics(message('one line'))
|
||||
const bare = estimateNativeChatRowHeight(metrics, NO_CHROME)
|
||||
const withStatus = estimateNativeChatRowHeight(metrics, { ...NO_CHROME, hasStatus: true })
|
||||
|
||||
expect(withStatus - bare).toBeGreaterThan(NATIVE_CHAT_ROW_GAP_PX)
|
||||
})
|
||||
|
||||
it('does not add a leading gap to status-only or diff-only rows', () => {
|
||||
const empty = nativeChatRowContentMetrics(message(''))
|
||||
const bare = estimateNativeChatRowHeight(empty, NO_CHROME)
|
||||
const statusOnly = estimateNativeChatRowHeight(empty, { ...NO_CHROME, hasStatus: true })
|
||||
const diffOnly = estimateNativeChatRowHeight(empty, { ...NO_CHROME, hasTurnDiff: true })
|
||||
|
||||
expect(statusOnly).toBe(diffOnly)
|
||||
expect(statusOnly - bare).toBeLessThan(NATIVE_CHAT_ROW_GAP_PX)
|
||||
})
|
||||
|
||||
it('includes both rendered parts and their gap for a receipt carrying a diff', () => {
|
||||
const empty = nativeChatRowContentMetrics(message(''))
|
||||
const receipt = estimateNativeChatRowHeight(empty, {
|
||||
hasReceipt: true,
|
||||
hasStatus: false,
|
||||
hasTurnDiff: false
|
||||
})
|
||||
const diff = estimateNativeChatRowHeight(empty, {
|
||||
hasReceipt: false,
|
||||
hasStatus: false,
|
||||
hasTurnDiff: true
|
||||
})
|
||||
const together = estimateNativeChatRowHeight(empty, {
|
||||
hasReceipt: true,
|
||||
hasStatus: false,
|
||||
hasTurnDiff: true
|
||||
})
|
||||
|
||||
expect(together).toBe(receipt + NATIVE_CHAT_ROW_GAP_PX + diff)
|
||||
})
|
||||
|
||||
it('reuses one derivation per message', () => {
|
||||
const subject = message('cached')
|
||||
expect(nativeChatRowContentMetrics(subject)).toBe(nativeChatRowContentMetrics(subject))
|
||||
})
|
||||
|
||||
it('keeps role-specific chrome when two messages share their blocks', () => {
|
||||
const blocks: NativeChatMessage['blocks'] = [{ type: 'text', text: 'same content' }]
|
||||
const withRole = (role: NativeChatMessage['role']): NativeChatMessage => ({
|
||||
...message('same content', role),
|
||||
blocks
|
||||
})
|
||||
|
||||
expect(nativeChatRowContentMetrics(withRole('user')).role).toBe('user')
|
||||
expect(nativeChatRowContentMetrics(withRole('assistant')).role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('reserves more for a row carrying a tool run than for its prose alone', () => {
|
||||
const prose = nativeChatRowContentMetrics(message('ran something'))
|
||||
const withTool = nativeChatRowContentMetrics({
|
||||
id: 'tool',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'ran something' },
|
||||
{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }
|
||||
],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
})
|
||||
expect(estimateNativeChatRowHeight(withTool, NO_CHROME)).toBeGreaterThan(
|
||||
estimateNativeChatRowHeight(prose, NO_CHROME)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
// First-paint height for a transcript row. Windowing has to place every row —
|
||||
// including the thousand nobody has looked at — before any of them has been
|
||||
// measured, so the estimate only has to be close enough that the scrollbar
|
||||
// doesn't lurch once the real measurement lands.
|
||||
//
|
||||
// Deliberately arithmetic over already-derived content: `estimateSize` is called
|
||||
// once per item every time a measurement resolves, so anything that walks blocks
|
||||
// or joins strings here would turn one row's ResizeObserver callback into a
|
||||
// whole-transcript scan.
|
||||
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { deriveNativeChatRowContent } from './native-chat-row-content'
|
||||
|
||||
/** What a row contains, reduced to the few numbers that drive its height. */
|
||||
export type NativeChatRowContentMetrics = {
|
||||
role: NativeChatMessage['role']
|
||||
/** Wrapped display lines of prose, not source lines. */
|
||||
textLines: number
|
||||
imageCount: number
|
||||
toolCount: number
|
||||
subagentGroupCount: number
|
||||
}
|
||||
|
||||
/** Extras the message itself doesn't know about — they come from the turn. */
|
||||
export type NativeChatRowChromeMetrics = {
|
||||
hasReceipt: boolean
|
||||
hasStatus: boolean
|
||||
hasTurnDiff: boolean
|
||||
}
|
||||
|
||||
const LINE_HEIGHT_PX = 22
|
||||
const CHARS_PER_LINE = 96
|
||||
const PROSE_MIN_LINES = 1
|
||||
const USER_BUBBLE_CHROME_PX = 32
|
||||
const IMAGE_STRIP_PX = 88
|
||||
const TOOL_RUN_PX = 40
|
||||
const SUBAGENT_ROW_PX = 32
|
||||
const STATUS_ROW_PX = 28
|
||||
const TURN_DIFF_PX = 28
|
||||
const RECEIPT_PX = 56
|
||||
const ROW_MIN_PX = 24
|
||||
/** `gap-5` between the parts stacked inside one row's wrapper. The identical gap
|
||||
* BETWEEN rows is the virtualizer's `gap` option and must never be added here:
|
||||
* counted in both places every row would sit 20px lower than the one above it. */
|
||||
export const NATIVE_CHAT_ROW_GAP_PX = 20
|
||||
// A single row can legitimately be enormous (a pasted file, an open diff). The
|
||||
// cap only bounds the *estimate*: measurement replaces it as soon as the row
|
||||
// mounts, and an estimate the size of ten viewports makes the scrollbar useless
|
||||
// until then.
|
||||
const ROW_MAX_PX = 1600
|
||||
|
||||
/** Wrapped line count for a markdown body, counting hard breaks and soft wraps. */
|
||||
export function estimateNativeChatTextLines(markdown: string): number {
|
||||
if (markdown.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let lines = 0
|
||||
let lineStart = 0
|
||||
for (let index = 0; index <= markdown.length; index += 1) {
|
||||
if (index === markdown.length || markdown[index] === '\n') {
|
||||
const length = index - lineStart
|
||||
lines += Math.max(PROSE_MIN_LINES, Math.ceil(length / CHARS_PER_LINE))
|
||||
lineStart = index + 1
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const metricsCache = new WeakMap<NativeChatMessage, NativeChatRowContentMetrics>()
|
||||
|
||||
/** Cached on the message, so its role remains part of the identity and a streaming turn
|
||||
* re-deriving on every frame pays for the changed row only. */
|
||||
export function nativeChatRowContentMetrics(
|
||||
message: NativeChatMessage
|
||||
): NativeChatRowContentMetrics {
|
||||
const cached = metricsCache.get(message)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const content = deriveNativeChatRowContent(message.blocks)
|
||||
const metrics: NativeChatRowContentMetrics = {
|
||||
role: message.role,
|
||||
textLines: estimateNativeChatTextLines(content.markdown),
|
||||
imageCount: content.prose.filter((block) => block.type === 'image-ref').length,
|
||||
toolCount: content.tools.length,
|
||||
subagentGroupCount: content.subagentGroups.length
|
||||
}
|
||||
metricsCache.set(message, metrics)
|
||||
return metrics
|
||||
}
|
||||
|
||||
export function estimateNativeChatRowHeight(
|
||||
content: NativeChatRowContentMetrics,
|
||||
chrome: NativeChatRowChromeMetrics
|
||||
): number {
|
||||
let partCount = 0
|
||||
let height = 0
|
||||
if (chrome.hasReceipt) {
|
||||
height = RECEIPT_PX
|
||||
partCount = 1
|
||||
} else {
|
||||
height = content.textLines * LINE_HEIGHT_PX
|
||||
if (content.role === 'user' && content.textLines > 0) {
|
||||
height += USER_BUBBLE_CHROME_PX
|
||||
}
|
||||
if (content.imageCount > 0) {
|
||||
height += IMAGE_STRIP_PX
|
||||
}
|
||||
if (content.toolCount > 0) {
|
||||
// A run is one collapsed header by default; its members only exist while open.
|
||||
height += TOOL_RUN_PX
|
||||
}
|
||||
height += content.subagentGroupCount * SUBAGENT_ROW_PX
|
||||
partCount = height > 0 ? 1 : 0
|
||||
}
|
||||
if (chrome.hasStatus) {
|
||||
height += STATUS_ROW_PX
|
||||
partCount += 1
|
||||
}
|
||||
if (chrome.hasTurnDiff) {
|
||||
height += TURN_DIFF_PX
|
||||
partCount += 1
|
||||
}
|
||||
height += Math.max(0, partCount - 1) * NATIVE_CHAT_ROW_GAP_PX
|
||||
return Math.min(ROW_MAX_PX, Math.max(ROW_MIN_PX, height))
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status'
|
||||
import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import {
|
||||
buildNativeChatTranscriptSlots,
|
||||
nativeChatSlotIndexOf
|
||||
} from './native-chat-transcript-slots'
|
||||
|
||||
const NO_STATUSES = { active: null, completedByTurn: {} }
|
||||
|
||||
function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
blocks: [{ type: 'text' as const, text: body }],
|
||||
timestamp: 1,
|
||||
source: 'transcript' as const
|
||||
}
|
||||
}
|
||||
|
||||
function build(
|
||||
messages: NativeChatMessage[],
|
||||
overrides: Partial<Parameters<typeof buildNativeChatTranscriptSlots>[0]> = {}
|
||||
) {
|
||||
let turn: string | undefined
|
||||
const turnKeys = messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
turn = message.id
|
||||
}
|
||||
return turn
|
||||
})
|
||||
return buildNativeChatTranscriptSlots({
|
||||
messages,
|
||||
turnKeys,
|
||||
latestUserIndex: messages.findLastIndex((message) => message.role === 'user'),
|
||||
currentTurnKey: undefined,
|
||||
receipts: new Map<string, NativeChatResolvedPrompt>(),
|
||||
turnStatuses: NO_STATUSES,
|
||||
turnDiffs: new Map<string, NativeChatTurnDiff>(),
|
||||
showTurnStatus: true,
|
||||
showTypingIndicator: false,
|
||||
isWorking: false,
|
||||
lifecycleWorking: false,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('transcript slots', () => {
|
||||
// A counted row that draws nothing is a gap in the transcript: it reserves
|
||||
// estimated height for a bubble that never appears.
|
||||
it('gives no slot to a message with nothing to draw', () => {
|
||||
const slots = build([text('a', 'visible'), text('blank', ''), text('b', 'also visible')])
|
||||
expect(slots.map((slot) => slot.message.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps a message whose only content is a turn status under it', () => {
|
||||
const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 }
|
||||
const slots = build([text('u', '', 'user')], {
|
||||
latestUserIndex: 0,
|
||||
turnStatuses: { active: status, completedByTurn: {} }
|
||||
})
|
||||
expect(slots).toHaveLength(1)
|
||||
expect(slots[0]?.status).toBe(status)
|
||||
})
|
||||
|
||||
it('keeps a message whose only content is its turn diff rollup', () => {
|
||||
const diff: NativeChatTurnDiff = { files: [], added: 1, removed: 0, truncated: false }
|
||||
const slots = build([text('u', 'ask', 'user'), text('blank', '')], {
|
||||
turnDiffs: new Map([['u', diff]])
|
||||
})
|
||||
expect(slots.map((slot) => slot.message.id)).toEqual(['u', 'blank'])
|
||||
expect(slots[1]?.turnDiff).toBe(diff)
|
||||
})
|
||||
|
||||
it('keeps a resolved prompt that stands in for a message drawing nothing', () => {
|
||||
const receipt = {
|
||||
kind: 'approval',
|
||||
title: 'Run it?',
|
||||
resolution: { state: 'resolved', selectedOptionId: 'yes' }
|
||||
} as unknown as NativeChatResolvedPrompt
|
||||
const slots = build([text('blank', '')], { receipts: new Map([['blank', receipt]]) })
|
||||
expect(slots).toHaveLength(1)
|
||||
expect(slots[0]?.receipt).toBe(receipt)
|
||||
})
|
||||
|
||||
it('hides the running turn status until the turn has something to say', () => {
|
||||
const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null }
|
||||
const slots = build([text('u', 'ask', 'user')], {
|
||||
latestUserIndex: 0,
|
||||
turnStatuses: { active: status, completedByTurn: {} },
|
||||
isWorking: true,
|
||||
showTypingIndicator: false
|
||||
})
|
||||
expect(slots[0]?.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reserves a height for every slot it keeps', () => {
|
||||
for (const slot of build([text('a', 'one'), text('b', 'two\nlines')])) {
|
||||
expect(slot.estimatedHeight).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('finds the slot a reveal names, and reports -1 for one that has no slot', () => {
|
||||
const slots = build([text('a', 'visible'), text('blank', ''), text('b', 'also visible')])
|
||||
expect(nativeChatSlotIndexOf(slots, 'b')).toBe(1)
|
||||
expect(nativeChatSlotIndexOf(slots, 'blank')).toBe(-1)
|
||||
expect(nativeChatSlotIndexOf(slots, undefined)).toBe(-1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// One transcript slot per row the reader can actually see.
|
||||
//
|
||||
// Without windowing "a message that draws nothing" costs nothing: React renders
|
||||
// null and the flex column lays out what's left. With windowing every entry is a
|
||||
// counted index that reserves estimated height, so a message the list counts and
|
||||
// the row declines to draw becomes a gap in the transcript. This module is the
|
||||
// single place that answers "does this message take a slot?", and it answers it
|
||||
// with the same derivation the row itself renders from.
|
||||
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status'
|
||||
import { nativeChatRowRendersContent } from './native-chat-row-content'
|
||||
import {
|
||||
estimateNativeChatRowHeight,
|
||||
nativeChatRowContentMetrics
|
||||
} from './native-chat-row-height-estimate'
|
||||
import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
|
||||
export type NativeChatTranscriptSlot = {
|
||||
message: NativeChatMessage
|
||||
turnKey: string | undefined
|
||||
/** The row's own turn is the one still running, so its tools stay live. */
|
||||
activeTurnIsWorking: boolean
|
||||
/** Resolved approval/question stands in for the message it answered. */
|
||||
receipt: NativeChatResolvedPrompt | undefined
|
||||
/** Turn timing shown under this row, already filtered to "should render". */
|
||||
status: NativeChatTurnStatus | undefined
|
||||
turnDiff: NativeChatTurnDiff | undefined
|
||||
/** Height to reserve before the row has ever been measured. */
|
||||
estimatedHeight: number
|
||||
}
|
||||
|
||||
export type NativeChatTranscriptSlotsInput = {
|
||||
messages: readonly NativeChatMessage[]
|
||||
turnKeys: readonly (string | undefined)[]
|
||||
latestUserIndex: number
|
||||
currentTurnKey: string | undefined
|
||||
receipts: ReadonlyMap<string, NativeChatResolvedPrompt>
|
||||
turnStatuses: {
|
||||
active: NativeChatTurnStatus | null
|
||||
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
|
||||
}
|
||||
turnDiffs: ReadonlyMap<string, NativeChatTurnDiff>
|
||||
showTurnStatus: boolean
|
||||
showTypingIndicator: boolean
|
||||
isWorking: boolean
|
||||
/** Session-level lifecycle, which outlives a transcript that never said "done". */
|
||||
lifecycleWorking: boolean
|
||||
}
|
||||
|
||||
export function buildNativeChatTranscriptSlots(
|
||||
input: NativeChatTranscriptSlotsInput
|
||||
): NativeChatTranscriptSlot[] {
|
||||
const {
|
||||
messages,
|
||||
turnKeys,
|
||||
latestUserIndex,
|
||||
currentTurnKey,
|
||||
receipts,
|
||||
turnStatuses,
|
||||
turnDiffs,
|
||||
showTurnStatus,
|
||||
showTypingIndicator,
|
||||
isWorking,
|
||||
lifecycleWorking
|
||||
} = input
|
||||
const slots: NativeChatTranscriptSlot[] = []
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const turnKey = turnKeys[index]
|
||||
const receipt = receipts.get(message.id)
|
||||
const candidateStatus =
|
||||
index === latestUserIndex
|
||||
? turnStatuses.active
|
||||
: message.role === 'user' && turnKey
|
||||
? turnStatuses.completedByTurn[turnKey]
|
||||
: undefined
|
||||
const status =
|
||||
showTurnStatus &&
|
||||
candidateStatus &&
|
||||
(index !== latestUserIndex || showTypingIndicator || !isWorking)
|
||||
? candidateStatus
|
||||
: undefined
|
||||
const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined
|
||||
const drawsRow = receipt !== undefined || nativeChatRowRendersContent(message.blocks)
|
||||
if (!drawsRow && status === undefined && turnDiff === undefined) {
|
||||
continue
|
||||
}
|
||||
slots.push({
|
||||
message,
|
||||
turnKey,
|
||||
activeTurnIsWorking:
|
||||
showTurnStatus &&
|
||||
(currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) &&
|
||||
(isWorking || lifecycleWorking),
|
||||
receipt,
|
||||
status: status ?? undefined,
|
||||
turnDiff,
|
||||
estimatedHeight: estimateNativeChatRowHeight(nativeChatRowContentMetrics(message), {
|
||||
hasReceipt: receipt !== undefined,
|
||||
hasStatus: status !== undefined,
|
||||
hasTurnDiff: turnDiff !== undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
/** Slot index of a message id, or -1. Reveal targets arrive as ids because the
|
||||
* row that owns them may not be mounted to be pointed at. */
|
||||
export function nativeChatSlotIndexOf(
|
||||
slots: readonly NativeChatTranscriptSlot[],
|
||||
messageId: string | undefined
|
||||
): number {
|
||||
if (messageId === undefined) {
|
||||
return -1
|
||||
}
|
||||
return slots.findIndex((slot) => slot.message.id === messageId)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// The transcript's scroll behaviour: staying pinned to the bottom while a turn
|
||||
// streams, offering the way back when the reader has left, aligning a row or a
|
||||
// card to the top, and paging in older history.
|
||||
//
|
||||
// Split from the list because windowing changed what these have to be careful
|
||||
// about, not what they decide: rows resolving their measured height move the
|
||||
// content constantly, so "the content changed" and "the reader scrolled" stopped
|
||||
// being the same event and only the latter may ask for another page.
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
isNearBottom,
|
||||
shouldLoadEarlier,
|
||||
shouldShowJumpToLatest,
|
||||
type ScrollGeometry
|
||||
} from './native-chat-autoscroll'
|
||||
|
||||
function geometryOf(element: HTMLElement): ScrollGeometry {
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeChatTranscriptScroll = {
|
||||
showJump: boolean
|
||||
onScroll: () => void
|
||||
scrollToBottom: () => void
|
||||
/** Align an element inside the transcript with the top of the viewport. */
|
||||
scrollMessageToTop: (element: HTMLElement) => void
|
||||
}
|
||||
|
||||
export function useNativeChatTranscriptScroll({
|
||||
scrollRef,
|
||||
contentRef,
|
||||
itemCount,
|
||||
isWorking,
|
||||
showTypingIndicator,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
loadEarlier,
|
||||
alignToViewportTop
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
contentRef: React.RefObject<HTMLDivElement | null>
|
||||
itemCount: number
|
||||
isWorking: boolean
|
||||
showTypingIndicator: boolean
|
||||
hasMore: boolean
|
||||
loadingEarlier: boolean
|
||||
loadEarlier: () => void
|
||||
alignToViewportTop: (element: HTMLElement) => void
|
||||
}): NativeChatTranscriptScroll {
|
||||
const [showJump, setShowJump] = useState(false)
|
||||
const stuckToBottomRef = useRef(true)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
const loadEarlierRequestedAtRef = useRef<number | null>(null)
|
||||
|
||||
const syncScrollState = useCallback((): ScrollGeometry | null => {
|
||||
const element = scrollRef.current
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
const geometry = geometryOf(element)
|
||||
const stick = isNearBottom(geometry)
|
||||
stuckToBottomRef.current = stick
|
||||
setShowJump(shouldShowJumpToLatest(stick, geometry))
|
||||
return geometry
|
||||
}, [scrollRef])
|
||||
|
||||
// Only a real scroll event pages in older history. Every row that resolves its
|
||||
// true height moves the content and re-fires the size observers; routing those
|
||||
// through here too would ask for the next page once per measurement.
|
||||
const onScroll = useCallback(() => {
|
||||
const geometry = syncScrollState()
|
||||
if (!geometry) {
|
||||
return
|
||||
}
|
||||
const previousScrollTop = previousScrollTopRef.current
|
||||
previousScrollTopRef.current = geometry.scrollTop
|
||||
if (
|
||||
shouldLoadEarlier({
|
||||
geometry,
|
||||
previousScrollTop,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
itemCount,
|
||||
requestedAtItemCount: loadEarlierRequestedAtRef.current
|
||||
})
|
||||
) {
|
||||
loadEarlierRequestedAtRef.current = itemCount
|
||||
loadEarlier()
|
||||
}
|
||||
}, [hasMore, itemCount, loadEarlier, loadingEarlier, syncScrollState])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const element = scrollRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
// The document's own bottom, not the window's last row: the typing indicator,
|
||||
// the activity line and the column's end padding all live past it.
|
||||
element.scrollTop = element.scrollHeight
|
||||
stuckToBottomRef.current = true
|
||||
setShowJump(false)
|
||||
}, [scrollRef])
|
||||
|
||||
const scrollMessageToTop = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
stuckToBottomRef.current = false
|
||||
alignToViewportTop(element)
|
||||
},
|
||||
[alignToViewportTop]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (stuckToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [itemCount, isWorking, showTypingIndicator, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current
|
||||
if (!element || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (stuckToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
syncScrollState()
|
||||
}
|
||||
})
|
||||
// Observe the growing content, not just the fixed-height viewport, so an
|
||||
// in-place streaming growth is seen; also watch the viewport for reflows.
|
||||
observer.observe(element)
|
||||
if (contentRef.current) {
|
||||
observer.observe(contentRef.current)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [contentRef, scrollRef, scrollToBottom, syncScrollState])
|
||||
|
||||
return { showJump, onScroll, scrollToBottom, scrollMessageToTop }
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import type { VirtualItem } from '@tanstack/react-virtual'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
|
||||
type VirtualizerOptionsCapture = {
|
||||
current:
|
||||
| ({ count: number; getItemKey: (index: number) => VirtualItem['key'] } & Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
| null
|
||||
}
|
||||
|
||||
const virtualizerMock = vi.hoisted(() => ({
|
||||
options: { current: null } as VirtualizerOptionsCapture,
|
||||
getTotalSize: vi.fn(() => 0),
|
||||
getVirtualItems: vi.fn(() => []),
|
||||
measureElement: vi.fn(),
|
||||
measure: vi.fn(),
|
||||
resizeItem: vi.fn(),
|
||||
scrollToOffset: vi.fn(),
|
||||
takeSnapshot: vi.fn<() => VirtualItem[]>(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: (options: VirtualizerOptionsCapture['current']) => {
|
||||
virtualizerMock.options.current = options
|
||||
return { ...virtualizerMock, scrollElement: null }
|
||||
}
|
||||
}))
|
||||
|
||||
const { MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS, useNativeChatTranscriptWindow } =
|
||||
await import('./use-native-chat-transcript-window')
|
||||
|
||||
function slot(id: string): NativeChatTranscriptSlot {
|
||||
return {
|
||||
message: {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: id }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
},
|
||||
turnKey: undefined,
|
||||
activeTurnIsWorking: false,
|
||||
receipt: undefined,
|
||||
status: undefined,
|
||||
turnDiff: undefined,
|
||||
estimatedHeight: 48
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
virtualizerMock.options.current = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('native chat transcript virtualizer contract', () => {
|
||||
it('configures prepend anchoring and matching bottom-follow behavior', () => {
|
||||
renderHook(() =>
|
||||
useNativeChatTranscriptWindow({
|
||||
scrollRef: { current: null },
|
||||
slots: [],
|
||||
revealIndex: -1
|
||||
})
|
||||
)
|
||||
|
||||
expect(virtualizerMock.options.current).toMatchObject({
|
||||
anchorTo: 'end',
|
||||
followOnAppend: true,
|
||||
scrollEndThreshold: 48
|
||||
})
|
||||
})
|
||||
|
||||
it('periodically resets retired measurements while restoring live measured sizes', () => {
|
||||
const scrollElement = document.createElement('div')
|
||||
scrollElement.scrollTop = 320
|
||||
virtualizerMock.takeSnapshot.mockImplementation(() => {
|
||||
const key = virtualizerMock.options.current?.getItemKey(0) ?? 'message-0'
|
||||
return [{ index: 0, key, start: 0, size: 96, end: 96, lane: 0 }]
|
||||
})
|
||||
const { rerender } = renderHook(
|
||||
({ id }) =>
|
||||
useNativeChatTranscriptWindow({
|
||||
scrollRef: { current: scrollElement },
|
||||
slots: [slot(id)],
|
||||
revealIndex: -1
|
||||
}),
|
||||
{ initialProps: { id: 'message-0' } }
|
||||
)
|
||||
|
||||
for (let index = 1; index <= MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS; index += 1) {
|
||||
rerender({ id: `message-${index}` })
|
||||
}
|
||||
|
||||
expect(virtualizerMock.measure).toHaveBeenCalledOnce()
|
||||
expect(virtualizerMock.resizeItem).toHaveBeenCalledExactlyOnceWith(0, 96)
|
||||
expect(virtualizerMock.scrollToOffset).toHaveBeenCalledExactlyOnceWith(320)
|
||||
})
|
||||
|
||||
it('keeps item-key lookup stable across content-only row revisions', () => {
|
||||
const scrollRef = { current: null }
|
||||
const { rerender } = renderHook(
|
||||
({ text }) => {
|
||||
const current = slot('message-0')
|
||||
current.message.blocks = [{ type: 'text', text }]
|
||||
return useNativeChatTranscriptWindow({ scrollRef, slots: [current], revealIndex: -1 })
|
||||
},
|
||||
{ initialProps: { text: 'first' } }
|
||||
)
|
||||
const getItemKey = virtualizerMock.options.current?.getItemKey
|
||||
|
||||
rerender({ text: 'streamed revision' })
|
||||
|
||||
expect(virtualizerMock.options.current?.getItemKey).toBe(getItemKey)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
// DOM windowing for the transcript: only the rows near the viewport are mounted,
|
||||
// the rest are reserved as estimated height.
|
||||
//
|
||||
// Anchoring is the library's, not ours. `anchorTo: 'end'` captures the row at the
|
||||
// current offset before a count change and re-resolves its position afterwards,
|
||||
// which is what keeps a "load earlier" prepend from yanking the view;
|
||||
// `followOnAppend` + `scrollEndThreshold` keep a reader who is already at the
|
||||
// bottom pinned there as a turn streams.
|
||||
//
|
||||
// Every measurement here ends up in the scroll container's own coordinate space,
|
||||
// which means `offsetTop` / `offsetHeight` rather than a bounding rect. The
|
||||
// transcript is zoomable, and a rect is in viewport pixels while `scrollTop` is
|
||||
// not: mixing the two puts the window out of place by exactly the zoom factor.
|
||||
// One path does read rects, and it converts them back before using them.
|
||||
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||
import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
|
||||
import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate'
|
||||
import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
|
||||
/** 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
|
||||
/** 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. */
|
||||
export const MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS = 512
|
||||
|
||||
export type NativeChatTranscriptWindow = {
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
scrollMargin: number
|
||||
sizerRef: (node: HTMLDivElement | null) => void
|
||||
measureRow: (node: HTMLElement | null) => void
|
||||
/** Scroll so this element's top meets the top of the viewport. */
|
||||
alignToViewportTop: (element: HTMLElement) => 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
|
||||
}
|
||||
|
||||
/** Same distance read off rects, for the case where there is no `offsetParent`
|
||||
* chain to walk. Rects are viewport pixels, so the container's own measured
|
||||
* zoom converts them back; a container with no layout reports no zoom and no
|
||||
* distance, which leaves the offset where it already is. */
|
||||
function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const zoom =
|
||||
container.offsetHeight > 0 && containerRect.height > 0
|
||||
? containerRect.height / container.offsetHeight
|
||||
: 1
|
||||
return container.scrollTop + (element.getBoundingClientRect().top - containerRect.top) / zoom
|
||||
}
|
||||
|
||||
export function useNativeChatTranscriptWindow({
|
||||
scrollRef,
|
||||
slots,
|
||||
revealIndex
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
slots: readonly NativeChatTranscriptSlot[]
|
||||
/** Slot the transcript was asked to reveal, or -1. */
|
||||
revealIndex: number
|
||||
}): NativeChatTranscriptWindow {
|
||||
const sizerElementRef = useRef<HTMLDivElement | null>(null)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
const previousMeasurementKeysRef = useRef<ReadonlySet<string> | null>(null)
|
||||
const retiredMeasurementCountRef = useRef(0)
|
||||
const pinned = useMemo(
|
||||
() => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }),
|
||||
[slots.length, revealIndex]
|
||||
)
|
||||
// A content-only tail revision must not rebuild measured offsets: doing so
|
||||
// breaks the end anchor while the row grows. Structural changes replace it.
|
||||
const encodedItemKeys = JSON.stringify(slots.map((slot) => slot.message.id))
|
||||
const itemKeys = useMemo(() => JSON.parse(encodedItemKeys) as string[], [encodedItemKeys])
|
||||
const estimateSize = useCallback(
|
||||
(index: number) => slots[index]?.estimatedHeight ?? FALLBACK_ROW_PX,
|
||||
[slots]
|
||||
)
|
||||
const getItemKey = useCallback((index: number) => itemKeys[index] ?? index, [itemKeys])
|
||||
// Identity tracks the pinned set on purpose. The virtualizer memoizes the
|
||||
// mounted indexes on this function, so a stable one would keep serving the
|
||||
// range from before a row was pinned — and a reveal would point at a row that
|
||||
// never mounted. It is not a dependency of the measurement memo, so nothing
|
||||
// expensive is rebuilt by changing it.
|
||||
const rangeExtractor = useCallback(
|
||||
(range: { startIndex: number; endIndex: number; overscan: number; count: number }) =>
|
||||
nativeChatTranscriptRange(range, pinned),
|
||||
[pinned]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: slots.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize,
|
||||
getItemKey,
|
||||
rangeExtractor,
|
||||
overscan: NATIVE_CHAT_WINDOW_OVERSCAN,
|
||||
gap: NATIVE_CHAT_ROW_GAP_PX,
|
||||
scrollMargin,
|
||||
anchorTo: 'end',
|
||||
followOnAppend: true,
|
||||
scrollEndThreshold: NATIVE_CHAT_BOTTOM_THRESHOLD_PX
|
||||
})
|
||||
|
||||
// Read, never assumed: the "load earlier" button sits above the window and
|
||||
// appears exactly when a prepend is about to land, which is the one moment a
|
||||
// stale margin would place every row wrong.
|
||||
const readScrollMargin = useCallback(() => {
|
||||
const container = scrollRef.current
|
||||
const sizer = sizerElementRef.current
|
||||
if (!container || !sizer) {
|
||||
return
|
||||
}
|
||||
// Only the offset chain, never the rect fallback: a container with no
|
||||
// layout would report the scroll position itself as the margin, which would
|
||||
// hold the window at the top of the transcript no matter where it scrolled.
|
||||
const offset = nativeChatScrollOffsetWithin(sizer, container)
|
||||
if (offset !== null) {
|
||||
setScrollMargin((current) => (current === offset ? current : offset))
|
||||
}
|
||||
}, [scrollRef])
|
||||
useLayoutEffect(readScrollMargin)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
readScrollMargin()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver(readScrollMargin)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [readScrollMargin, scrollRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const currentKeys = new Set(itemKeys)
|
||||
const previousKeys = previousMeasurementKeysRef.current
|
||||
previousMeasurementKeysRef.current = currentKeys
|
||||
if (previousKeys !== null) {
|
||||
for (const key of previousKeys) {
|
||||
if (!currentKeys.has(key)) {
|
||||
retiredMeasurementCountRef.current += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retiredMeasurementCountRef.current < MAX_RETIRED_NATIVE_CHAT_MEASUREMENTS) {
|
||||
return
|
||||
}
|
||||
|
||||
const retainedMeasurements = virtualizer
|
||||
.takeSnapshot()
|
||||
.filter((item) => typeof item.key === 'string' && currentKeys.has(item.key))
|
||||
const scrollTop = scrollRef.current?.scrollTop
|
||||
virtualizer.measure()
|
||||
// Materialize the estimate-only layout before restoring retained sizes.
|
||||
virtualizer.getTotalSize()
|
||||
for (const item of retainedMeasurements) {
|
||||
virtualizer.resizeItem(item.index, item.size)
|
||||
}
|
||||
if (scrollTop !== undefined) {
|
||||
virtualizer.scrollToOffset(scrollTop)
|
||||
}
|
||||
retiredMeasurementCountRef.current = 0
|
||||
}, [itemKeys, scrollRef, virtualizer])
|
||||
|
||||
const sizerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
sizerElementRef.current = node
|
||||
if (node) {
|
||||
readScrollMargin()
|
||||
}
|
||||
},
|
||||
[readScrollMargin]
|
||||
)
|
||||
|
||||
const alignToViewportTop = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
const container = scrollRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const top =
|
||||
nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container)
|
||||
// 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: 'smooth' })
|
||||
} else {
|
||||
container.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
[scrollRef, virtualizer]
|
||||
)
|
||||
|
||||
return {
|
||||
virtualItems: virtualizer.getVirtualItems(),
|
||||
totalSize: virtualizer.getTotalSize(),
|
||||
scrollMargin,
|
||||
sizerRef,
|
||||
measureRow: virtualizer.measureElement,
|
||||
alignToViewportTop
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AgentJournalItemBodySchema,
|
||||
isAdmissibleAgentJournalItemBody,
|
||||
isAdmissibleAgentJournalMessageBody,
|
||||
isAdmissibleAgentJournalRenderItem,
|
||||
@@ -275,13 +276,20 @@ describe('optional tool annotations', () => {
|
||||
const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' }
|
||||
it('admits old rows and rows with optional annotations without a new kind', () => {
|
||||
expect(isAdmissibleAgentJournalItemBody(body)).toBe(true)
|
||||
expect(isAdmissibleAgentJournalItemBody({ ...body, exitCode: 0, durationMs: 0 })).toBe(true)
|
||||
expect(
|
||||
isAdmissibleAgentJournalItemBody({ ...body, callId: 'call-1', exitCode: 0, durationMs: 0 })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isAdmissibleAgentJournalItemBody({
|
||||
...body,
|
||||
webSearchResults: [{ title: 'Docs', url: 'https://example.com' }]
|
||||
})
|
||||
).toBe(true)
|
||||
const padded = AgentJournalItemBodySchema.safeParse({ ...body, callId: ' call-1 ' })
|
||||
expect(padded.success).toBe(true)
|
||||
if (padded.success && padded.data.kind === 'tool-call') {
|
||||
expect(padded.data.callId).toBe(' call-1 ')
|
||||
}
|
||||
})
|
||||
it('admits explicit MCP identity without constraining the raw name', () => {
|
||||
expect(
|
||||
@@ -293,6 +301,9 @@ describe('optional tool annotations', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
it.each([
|
||||
{ callId: '' },
|
||||
{ callId: ' \t' },
|
||||
{ callId: 1 },
|
||||
{ exitCode: '127' },
|
||||
{ exitCode: 1.5 },
|
||||
{ durationMs: -1 },
|
||||
@@ -300,4 +311,14 @@ describe('optional tool annotations', () => {
|
||||
])('rejects malformed annotation %s', (metadata) =>
|
||||
expect(isAdmissibleAgentJournalItemBody({ ...body, ...metadata })).toBe(false)
|
||||
)
|
||||
|
||||
it('rejects whitespace-only provider IDs in message blocks too', () => {
|
||||
expect(
|
||||
isAdmissibleAgentJournalItemBody({
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name: 'shell', input: null, callId: '\n\t' }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,11 @@ const KNOWN_BLOCK_TYPES = new Set([
|
||||
'subagent-group'
|
||||
])
|
||||
|
||||
/** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */
|
||||
const ProviderCallId = z
|
||||
.string()
|
||||
.refine((value) => value.trim().length > 0, 'callId must contain a non-whitespace character')
|
||||
|
||||
/** Child-agent lifecycle stays an open string for the same reason tool states
|
||||
* do: a state a newer build writes must not turn the row malformed. */
|
||||
const SubagentEntry = z.object({
|
||||
@@ -78,6 +83,7 @@ const Block = z.union([
|
||||
type: z.literal('tool-call'),
|
||||
name: z.string(),
|
||||
input: z.unknown().optional(),
|
||||
callId: ProviderCallId.optional(),
|
||||
...ToolMetadata
|
||||
}),
|
||||
z.object({
|
||||
@@ -140,6 +146,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
|
||||
name: z.string(),
|
||||
// See the tool-call block: the key itself is lost when `input` is undefined.
|
||||
input: z.unknown().optional(),
|
||||
callId: ProviderCallId.optional(),
|
||||
state: z.string().min(1),
|
||||
output: BoundedPayload.optional()
|
||||
}),
|
||||
|
||||
@@ -97,6 +97,8 @@ export type AgentJournalToolCallItem = NativeChatToolMetadata & {
|
||||
kind: 'tool-call'
|
||||
name: string
|
||||
input: unknown
|
||||
/** Provider-supplied identity within this item stream; optional for mixed-version peers. */
|
||||
callId?: string
|
||||
state: AgentJournalToolCallState
|
||||
output?: AgentJournalBoundedPayload
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export type NativeChatToolCallBlock = NativeChatToolMetadata & {
|
||||
type: 'tool-call'
|
||||
name: string
|
||||
input: unknown
|
||||
/** Provider-supplied identity within this item stream; absent on legacy transcripts and peers. */
|
||||
callId?: string
|
||||
/** Provider lifecycle when the structured app-server path can supply it. */
|
||||
state?: 'running' | 'completed' | 'failed'
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ describe('notice projection for desktop and mobile consumers', () => {
|
||||
|
||||
it('preserves optional tool annotations for desktop and mobile projection', () => {
|
||||
const metadata = {
|
||||
callId: 'call-1',
|
||||
exitCode: 127,
|
||||
durationMs: 400,
|
||||
webSearchResults: [{ title: 'Docs', url: 'https://example.com' }]
|
||||
|
||||
@@ -54,6 +54,7 @@ function itemBlocks(item: AgentJournalRenderItem): {
|
||||
name: body.name,
|
||||
input: body.input,
|
||||
state: body.state,
|
||||
...(body.callId !== undefined ? { callId: body.callId } : {}),
|
||||
...(body.mcpIdentity !== undefined ? { mcpIdentity: body.mcpIdentity } : {}),
|
||||
...(body.exitCode !== undefined ? { exitCode: body.exitCode } : {}),
|
||||
...(body.durationMs !== undefined ? { durationMs: body.durationMs } : {}),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import type { GlobalSettings } from '../../src/shared/global-settings-types'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
|
||||
|
||||
const TRANSCRIPT_ROWS = 650
|
||||
|
||||
async function enableNativeChatSetting(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const nextSettings = await window.api.settings.set({ experimentalNativeChat: true })
|
||||
window.__store?.setState({ settings: nextSettings as GlobalSettings })
|
||||
})
|
||||
}
|
||||
|
||||
async function seedClaudeProviderSession(
|
||||
page: Page,
|
||||
args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string }
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => {
|
||||
window.__store
|
||||
?.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'working', prompt: 'e2e history anchor probe', agentType: 'claude' },
|
||||
'Claude',
|
||||
undefined,
|
||||
{ worktreeId },
|
||||
{ providerSession: { key: 'session_id', id: sessionId, transcriptPath } }
|
||||
)
|
||||
}, args)
|
||||
}
|
||||
|
||||
async function toggleTerminalTabToChatView(
|
||||
page: Page,
|
||||
args: { tabId: string; worktreeId: string }
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ tabId, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(tab) => tab.contentType === 'terminal' && tab.entityId === tabId
|
||||
)
|
||||
if (!unifiedTab) {
|
||||
throw new Error('Unified terminal tab not found for chat toggle')
|
||||
}
|
||||
state.toggleTabViewMode(unifiedTab.id)
|
||||
}, args)
|
||||
}
|
||||
|
||||
function claudeTranscript(rowCount: number, sessionId: string): string {
|
||||
const startedAt = Date.now() - rowCount * 1_000
|
||||
return `${Array.from({ length: rowCount }, (_, index) => {
|
||||
const marker = `E2E transcript row ${String(index).padStart(4, '0')}`
|
||||
const body = Array.from(
|
||||
{ length: 5 },
|
||||
(_unused, line) => `Measured paragraph ${line + 1} for row ${String(index).padStart(4, '0')}.`
|
||||
).join('\n\n')
|
||||
return JSON.stringify({
|
||||
sessionId,
|
||||
uuid: `${sessionId}-${index}`,
|
||||
timestamp: new Date(startedAt + index * 1_000).toISOString(),
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
message: {
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
model: 'claude-opus-4',
|
||||
content: [{ type: 'text', text: `${marker}\n\n${body}` }]
|
||||
}
|
||||
})
|
||||
}).join('\n')}\n`
|
||||
}
|
||||
|
||||
test.describe('Native chat history prepend anchoring', () => {
|
||||
test('keeps the visible transcript row at the same viewport offset', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
const [tabId] = descriptor.paneKey.split(':')
|
||||
const sessionId = `e2e-prepend-anchor-${randomUUID()}`
|
||||
const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-anchor-'))
|
||||
const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`)
|
||||
writeFileSync(transcriptPath, claudeTranscript(TRANSCRIPT_ROWS, sessionId))
|
||||
|
||||
try {
|
||||
await enableNativeChatSetting(orcaPage)
|
||||
await seedClaudeProviderSession(orcaPage, {
|
||||
paneKey: descriptor.paneKey,
|
||||
worktreeId: descriptor.worktreeId,
|
||||
sessionId,
|
||||
transcriptPath
|
||||
})
|
||||
await toggleTerminalTabToChatView(orcaPage, {
|
||||
tabId,
|
||||
worktreeId: descriptor.worktreeId
|
||||
})
|
||||
|
||||
await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
const scroll = orcaPage.locator('[data-native-chat-scroll]')
|
||||
const transcriptWindow = orcaPage.locator('[data-native-chat-window]')
|
||||
const loadEarlier = orcaPage.getByRole('button', { name: 'Load earlier messages' })
|
||||
await expect(transcriptWindow).toBeVisible({ timeout: 30_000 })
|
||||
await expect(loadEarlier).toBeAttached({ timeout: 30_000 })
|
||||
await expect
|
||||
.poll(() => transcriptWindow.locator(':scope > [data-index]').count())
|
||||
.toBeGreaterThan(3)
|
||||
|
||||
const initialTotalSize = await transcriptWindow.evaluate((element) => element.offsetHeight)
|
||||
const anchor = await scroll.evaluate(async (element) => {
|
||||
element.scrollTop = element.scrollHeight * 0.55
|
||||
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
let previousGeometry = ''
|
||||
let stableFrames = 0
|
||||
for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
const geometry = `${element.scrollHeight}:${element.scrollTop}`
|
||||
stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0
|
||||
previousGeometry = geometry
|
||||
}
|
||||
const scrollRect = element.getBoundingClientRect()
|
||||
const candidates = Array.from(
|
||||
element.querySelectorAll<HTMLElement>('[data-native-chat-window] > [data-index]')
|
||||
).filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= scrollRect.top + 40 && rect.bottom <= scrollRect.bottom - 40
|
||||
})
|
||||
const row = candidates[Math.floor(candidates.length / 2)]
|
||||
const marker = row
|
||||
? Array.from(row.querySelectorAll('p')).find((paragraph) =>
|
||||
/^E2E transcript row \d{4}$/.test(paragraph.textContent?.trim() ?? '')
|
||||
)
|
||||
: undefined
|
||||
if (!row || !marker) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
index: Number(row.dataset.index),
|
||||
marker: marker.textContent?.trim() ?? '',
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
viewportOffset: row.getBoundingClientRect().top - scrollRect.top
|
||||
}
|
||||
})
|
||||
expect(anchor, 'expected a fully visible measured row to anchor').not.toBeNull()
|
||||
if (!anchor) {
|
||||
throw new Error('Expected a fully visible measured row to anchor')
|
||||
}
|
||||
|
||||
// The button stays off-screen so invoking it here exercises the real pagination
|
||||
// handler without Playwright first scrolling the reader to the transcript head.
|
||||
await loadEarlier.evaluate((button: HTMLButtonElement) => button.click())
|
||||
await expect
|
||||
.poll(() => transcriptWindow.evaluate((element) => element.offsetHeight))
|
||||
.toBeGreaterThan(initialTotalSize)
|
||||
await expect(loadEarlier).toBeAttached({ timeout: 30_000 })
|
||||
|
||||
const anchoredMarker = orcaPage.getByText(anchor.marker, { exact: true })
|
||||
await expect(anchoredMarker).toBeAttached({ timeout: 15_000 })
|
||||
const after = await anchoredMarker.evaluate(async (marker) => {
|
||||
const row = marker.closest<HTMLElement>('[data-index]')
|
||||
const scrollRoot = marker.closest<HTMLElement>('[data-native-chat-scroll]')
|
||||
if (!row || !scrollRoot) {
|
||||
return null
|
||||
}
|
||||
let previousGeometry = ''
|
||||
let stableFrames = 0
|
||||
for (let frame = 0; frame < 120 && stableFrames < 5; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
const geometry = `${scrollRoot.scrollHeight}:${scrollRoot.scrollTop}`
|
||||
stableFrames = geometry === previousGeometry ? stableFrames + 1 : 0
|
||||
previousGeometry = geometry
|
||||
}
|
||||
return {
|
||||
index: Number(row.dataset.index),
|
||||
scrollHeight: scrollRoot.scrollHeight,
|
||||
scrollTop: scrollRoot.scrollTop,
|
||||
viewportOffset: row.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top
|
||||
}
|
||||
})
|
||||
expect(after, 'anchored row must remain mounted after history prepends').not.toBeNull()
|
||||
expect(after?.index).toBe(anchor.index + 200)
|
||||
const contentGrowth = (after?.scrollHeight ?? 0) - anchor.scrollHeight
|
||||
const scrollAdjustment = (after?.scrollTop ?? 0) - anchor.scrollTop
|
||||
expect(
|
||||
Math.abs(contentGrowth - scrollAdjustment),
|
||||
`content grew ${contentGrowth}px while scrollTop adjusted ${scrollAdjustment}px`
|
||||
).toBeLessThanOrEqual(2)
|
||||
expect(Math.abs((after?.viewportOffset ?? 0) - anchor.viewportOffset)).toBeLessThanOrEqual(3)
|
||||
} finally {
|
||||
rmSync(scratchDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user