basalt-ui 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +17 -0
- package/README.md +38 -35
- package/agent/rules/basalt-agent.md +14 -0
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +5 -1
- package/dist/agent/adapter.d.ts.map +1 -1
- package/dist/agent/adapter.js +22 -13
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/id.d.ts +67 -19
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +21 -7
- package/dist/agent/thread.d.ts +4 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent-chat/index.d.ts +4 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +315 -34
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +29 -3
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +3 -2
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/llms.txt +6 -6
- package/package.json +2 -2
- package/src/agent/adapter.test.ts +237 -0
- package/src/agent/adapter.ts +72 -14
- package/src/agent/ai-sdk-transport.test.ts +64 -0
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/id.test.ts +95 -1
- package/src/agent/id.ts +98 -28
- package/src/agent/thread.ts +4 -0
- package/src/agent/use-agent-stream.test.tsx +22 -0
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.test.tsx +326 -0
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
- package/src/agent-chat/index.ts +12 -0
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +937 -3
- package/src/agent-chat/thread-message.tsx +766 -38
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +69 -5
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +5 -0
- package/src/surfaces.ts +2 -1
- package/src/theme/shadow-surfaces.test.ts +9 -0
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
Text,
|
|
30
30
|
UnstyledButton,
|
|
31
31
|
} from '@mantine/core'
|
|
32
|
-
import { useDisclosure } from '@mantine/hooks'
|
|
33
|
-
import { Fragment, memo, useMemo } from 'react'
|
|
32
|
+
import { useDisclosure, useFocusWithin, useHover, useMergedRef } from '@mantine/hooks'
|
|
33
|
+
import { Fragment, lazy, memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react'
|
|
34
34
|
import type { JSX } from 'react'
|
|
35
35
|
import type {
|
|
36
36
|
AgentPart,
|
|
@@ -49,8 +49,14 @@ import type {
|
|
|
49
49
|
} from '../agent'
|
|
50
50
|
import { coalesceParts, narrowAgentPart, PartList } from '../agent'
|
|
51
51
|
import { Markdown } from '../content/markdown'
|
|
52
|
+
import { CopyAction } from '../content/copy-action'
|
|
52
53
|
import { VX } from '../tokens'
|
|
54
|
+
import { DEFAULT_AFFORDANCES } from './message-affordances'
|
|
55
|
+
import type { MessageAffordances } from './message-affordances'
|
|
56
|
+
import { formatRelativeTime } from './relative-time'
|
|
53
57
|
import { ToolChip } from './tool-chip'
|
|
58
|
+
import { resolveVirtualize } from './virtualize'
|
|
59
|
+
import type { VirtualizeOptions, VirtualizeProps } from './virtualize'
|
|
54
60
|
|
|
55
61
|
/** The mono, uppercase, letter-spaced micro-label idiom (docs/DESIGN-SPEC.md §3) — shared by the
|
|
56
62
|
* transcript's role labels and the reasoning/tool-call headers below. */
|
|
@@ -266,6 +272,99 @@ function resolveSegments(
|
|
|
266
272
|
return segments
|
|
267
273
|
}
|
|
268
274
|
|
|
275
|
+
/** Concatenates every resolved `text` part across a message's segments, in order, joined by a
|
|
276
|
+
* blank line between non-adjacent runs (adjacent text parts are already merged by `coalesceParts`
|
|
277
|
+
* inside `resolveSegments`). This is what the per-message copy affordance copies — the message's
|
|
278
|
+
* COALESCED text, not its raw `parts`, so a message that streamed in several by-id text chunks
|
|
279
|
+
* copies one clean string rather than the fragments the user never saw assembled. Foreign
|
|
280
|
+
* (consumer-registered) segments carry no `AgentPart`-typed text and are skipped. */
|
|
281
|
+
function extractCoalescedText(segments: readonly ResolvedSegment[]): string {
|
|
282
|
+
const chunks: string[] = []
|
|
283
|
+
for (const segment of segments) {
|
|
284
|
+
if (segment.kind !== 'agent') continue
|
|
285
|
+
for (const part of segment.parts) {
|
|
286
|
+
if (part.type === 'text') chunks.push(part.text)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return chunks.join('\n\n')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── MessageAffordanceRow — the per-message hover row (AGENT-CHAT-SPEC.md §11) ─────────────────
|
|
293
|
+
|
|
294
|
+
const GROUP_WINDOW_MS = 5 * 60_000
|
|
295
|
+
|
|
296
|
+
/** True when `message` continues the SAME speaker's immediately-preceding turn within the Slack
|
|
297
|
+
* grouping window — the predecessor is absent (first message), a different role, or more than
|
|
298
|
+
* `GROUP_WINDOW_MS` apart all resolve to false. */
|
|
299
|
+
function isConsecutiveWithPredecessor(
|
|
300
|
+
message: ChatMessage<TranscriptPart>,
|
|
301
|
+
predecessor: ChatMessage<TranscriptPart> | undefined,
|
|
302
|
+
): boolean {
|
|
303
|
+
if (predecessor === undefined) return false
|
|
304
|
+
if (predecessor.role !== message.role) return false
|
|
305
|
+
return message.createdAt - predecessor.createdAt <= GROUP_WINDOW_MS
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
type MessageAffordanceRowProps = {
|
|
309
|
+
readonly message: ChatMessage<TranscriptPart>
|
|
310
|
+
readonly coalescedText: string
|
|
311
|
+
readonly affordances: MessageAffordances
|
|
312
|
+
readonly isLastAssistant: boolean
|
|
313
|
+
/** Driven by the parent block's hover state OR its focus-within state — a visual reveal-on-hover
|
|
314
|
+
* strip that must ALSO reveal for a sighted keyboard-only user tabbing a control into view (hover
|
|
315
|
+
* alone left a focused Copy/Regenerate control invisible, with no focus ring visible either). The
|
|
316
|
+
* row stays mounted (and clickable/focusable) at all times rather than being removed from the
|
|
317
|
+
* DOM — that part of "keyboard/AT users never lose the controls a mouse user gets for free" held
|
|
318
|
+
* even before this fix for screen-reader users (opacity keeps elements in the a11y tree); visible
|
|
319
|
+
* focus for SIGHTED keyboard users is the half that did not, until this `focused` term was added. */
|
|
320
|
+
readonly visible: boolean
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The per-message hover row: relative/absolute/none timestamp, a copy-coalesced-text action, a
|
|
324
|
+
* regenerate action (last assistant message only), and any consumer `actions`. Renders `null` when
|
|
325
|
+
* every affordance resolves to off (nothing to show, no empty strip taking up rhythm). */
|
|
326
|
+
function MessageAffordanceRow({
|
|
327
|
+
message,
|
|
328
|
+
coalescedText,
|
|
329
|
+
affordances,
|
|
330
|
+
isLastAssistant,
|
|
331
|
+
visible,
|
|
332
|
+
}: MessageAffordanceRowProps): JSX.Element | null {
|
|
333
|
+
const timestampMode = affordances.timestamp ?? DEFAULT_AFFORDANCES.timestamp
|
|
334
|
+
const showTimestamp = timestampMode !== 'none'
|
|
335
|
+
const showCopy = affordances.copy ?? DEFAULT_AFFORDANCES.copy
|
|
336
|
+
const showRegenerate = isLastAssistant && affordances.onRegenerate !== undefined
|
|
337
|
+
const customActions = affordances.actions?.({ message })
|
|
338
|
+
|
|
339
|
+
if (!showTimestamp && !showCopy && !showRegenerate && customActions === undefined) return null
|
|
340
|
+
|
|
341
|
+
const timestampLabel =
|
|
342
|
+
timestampMode === 'absolute'
|
|
343
|
+
? new Date(message.createdAt).toLocaleString()
|
|
344
|
+
: formatRelativeTime(message.createdAt)
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<Group
|
|
348
|
+
gap={8}
|
|
349
|
+
data-testid={`message-affordances-${message.id}`}
|
|
350
|
+
style={{ opacity: visible ? 1 : 0, transition: 'opacity 120ms ease' }}
|
|
351
|
+
>
|
|
352
|
+
{showTimestamp && (
|
|
353
|
+
<Text size="xs" c="dimmed">
|
|
354
|
+
{timestampLabel}
|
|
355
|
+
</Text>
|
|
356
|
+
)}
|
|
357
|
+
{showCopy && <CopyAction value={coalescedText} ariaLabel="Copy message" />}
|
|
358
|
+
{showRegenerate && (
|
|
359
|
+
<UnstyledButton onClick={() => affordances.onRegenerate?.(message.id)}>
|
|
360
|
+
<Text style={MICRO_LABEL_STYLE}>Regenerate</Text>
|
|
361
|
+
</UnstyledButton>
|
|
362
|
+
)}
|
|
363
|
+
{customActions}
|
|
364
|
+
</Group>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
269
368
|
// ── MessageBlock (memoized) ────────────────────────────────────────────────────
|
|
270
369
|
|
|
271
370
|
const ROLE_LABEL: Record<ChatMessage['role'], string> = {
|
|
@@ -294,6 +393,20 @@ type MessageBlockProps = {
|
|
|
294
393
|
readonly streaming?: boolean
|
|
295
394
|
readonly renderers: PartRenderers
|
|
296
395
|
readonly fallbackRenderer: PartRenderer<ForeignPart>
|
|
396
|
+
/** Suppresses the role label and surface chrome — this message continues the same speaker's
|
|
397
|
+
* immediately-preceding turn within the grouping window (the Slack rhythm, AGENT-CHAT-SPEC.md
|
|
398
|
+
* §_). Computed by `ThreadTranscript` (a property of the SEQUENCE, not of one message in
|
|
399
|
+
* isolation) and passed down. @default false */
|
|
400
|
+
readonly grouped?: boolean
|
|
401
|
+
/** Per-message hover-row affordances. `undefined` renders NO affordance row at all — this is how
|
|
402
|
+
* `ThreadTranscript` opts the live/streaming message out (an epoch-0 `createdAt` and a
|
|
403
|
+
* half-streamed coalesced text would otherwise render a nonsensical "56 years ago" timestamp and
|
|
404
|
+
* a copy button that copies a fragment). Every settled message gets a resolved (defaults-merged)
|
|
405
|
+
* object instead. */
|
|
406
|
+
readonly affordances?: MessageAffordances
|
|
407
|
+
/** True only for the single most-recent SETTLED assistant message — the sole message
|
|
408
|
+
* `affordances.onRegenerate` renders a control for. @default false */
|
|
409
|
+
readonly isLastAssistant?: boolean
|
|
297
410
|
}
|
|
298
411
|
|
|
299
412
|
/**
|
|
@@ -309,37 +422,71 @@ function MessageBlockImpl({
|
|
|
309
422
|
streaming = false,
|
|
310
423
|
renderers,
|
|
311
424
|
fallbackRenderer,
|
|
425
|
+
grouped = false,
|
|
426
|
+
affordances,
|
|
427
|
+
isLastAssistant = false,
|
|
312
428
|
}: MessageBlockProps): JSX.Element {
|
|
313
429
|
messageBlockRenderCounter.count += 1
|
|
314
430
|
const settled = !streaming
|
|
431
|
+
const { hovered, ref: hoverRef } = useHover<HTMLDivElement>()
|
|
432
|
+
// `focused` covers the sighted-keyboard-user gap hover alone leaves open (see
|
|
433
|
+
// `MessageAffordanceRowProps.visible`'s doc) — merged onto the SAME node as `hoverRef` via
|
|
434
|
+
// `useMergedRef` since both hooks need their own callback ref on this one element.
|
|
435
|
+
const { focused, ref: focusWithinRef } = useFocusWithin<HTMLDivElement>()
|
|
436
|
+
const ref = useMergedRef(hoverRef, focusWithinRef)
|
|
315
437
|
const segments = useMemo(
|
|
316
438
|
() => resolveSegments(message.parts, renderers, fallbackRenderer),
|
|
317
439
|
[message.parts, renderers, fallbackRenderer],
|
|
318
440
|
)
|
|
319
441
|
const finishIndicator =
|
|
320
442
|
message.finish === undefined ? undefined : FINISH_INDICATOR[message.finish]
|
|
443
|
+
// Guarded on `affordances`: the live/streaming message is pushed into `rows` WITHOUT this prop
|
|
444
|
+
// (see `MessageBlockProps.affordances`'s doc), so `MessageAffordanceRow` never renders for it —
|
|
445
|
+
// computing `extractCoalescedText` for it anyway would be pure waste, and `segments` for a live
|
|
446
|
+
// message grows every streamed chunk, so unconditionally recomputing it scaled total work across
|
|
447
|
+
// one streamed reply with the SQUARE of the response length.
|
|
448
|
+
const coalescedText = useMemo(
|
|
449
|
+
() => (affordances === undefined ? '' : extractCoalescedText(segments)),
|
|
450
|
+
[affordances, segments],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
// Grouped continuations drop the role label — but a streaming loader or a finish badge is
|
|
454
|
+
// per-message state (not sequence rhythm) and must still surface even mid-group, so the header
|
|
455
|
+
// row itself only disappears when there is truly nothing left for it to show.
|
|
456
|
+
const showHeader = !grouped || streaming || finishIndicator !== undefined
|
|
321
457
|
|
|
322
458
|
// Assistant/user surfaces are differentiated by subtle vs panel tokens, radius 7
|
|
323
|
-
// (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the
|
|
459
|
+
// (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the
|
|
460
|
+
// reply. A grouped continuation drops this chrome entirely (transparent, no shadow, no radius)
|
|
461
|
+
// — that absence, alongside the suppressed role label, IS the Slack rhythm.
|
|
324
462
|
return (
|
|
325
463
|
<Box
|
|
464
|
+
ref={ref}
|
|
465
|
+
data-testid={`agent-message-${message.id}`}
|
|
466
|
+
data-grouped={grouped}
|
|
326
467
|
style={{
|
|
327
468
|
padding: 'var(--vx-space-agent-message-inset-y) var(--vx-space-agent-message-inset-x)',
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
469
|
+
...(grouped
|
|
470
|
+
? {}
|
|
471
|
+
: {
|
|
472
|
+
backgroundColor: message.role === 'user' ? VX.surface.subtle : VX.surface.panel,
|
|
473
|
+
borderRadius: VX.radiusCard,
|
|
474
|
+
boxShadow: VX.shadowCard,
|
|
475
|
+
}),
|
|
331
476
|
}}
|
|
332
477
|
>
|
|
333
478
|
<Stack gap={6}>
|
|
334
|
-
|
|
335
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
{finishIndicator.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
479
|
+
{showHeader && (
|
|
480
|
+
<Group gap={6} align="center">
|
|
481
|
+
{!grouped && <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[message.role]}</Text>}
|
|
482
|
+
{streaming && <Loader size="xs" />}
|
|
483
|
+
{finishIndicator !== undefined && (
|
|
484
|
+
<Text style={{ ...MICRO_LABEL_STYLE, color: finishIndicator.statusToken }}>
|
|
485
|
+
{finishIndicator.label}
|
|
486
|
+
</Text>
|
|
487
|
+
)}
|
|
488
|
+
</Group>
|
|
489
|
+
)}
|
|
343
490
|
{segments.map((segment) =>
|
|
344
491
|
segment.kind === 'agent' ? (
|
|
345
492
|
<PartList
|
|
@@ -360,6 +507,15 @@ function MessageBlockImpl({
|
|
|
360
507
|
</Fragment>
|
|
361
508
|
),
|
|
362
509
|
)}
|
|
510
|
+
{affordances !== undefined && (
|
|
511
|
+
<MessageAffordanceRow
|
|
512
|
+
message={message}
|
|
513
|
+
coalescedText={coalescedText}
|
|
514
|
+
affordances={affordances}
|
|
515
|
+
isLastAssistant={isLastAssistant}
|
|
516
|
+
visible={hovered || focused}
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
363
519
|
</Stack>
|
|
364
520
|
</Box>
|
|
365
521
|
)
|
|
@@ -369,18 +525,431 @@ function MessageBlockImpl({
|
|
|
369
525
|
* re-render must not force through every settled message's `MessageBlock`. `renderers`/
|
|
370
526
|
* `fallbackRenderer` are included too: both are memoized once at the `ThreadTranscript` level (see
|
|
371
527
|
* below), so in practice they're stable across renders, but a genuine change to either (a consumer
|
|
372
|
-
* swapping its renderer map) must still invalidate the memo.
|
|
528
|
+
* swapping its renderer map) must still invalidate the memo. `grouped` and `isLastAssistant` are
|
|
529
|
+
* cheap booleans recomputed every `ThreadTranscript` render — including them keeps a message from
|
|
530
|
+
* rendering yesterday's grouping/regenerate-eligibility when a NEIGHBOUR changes, at negligible
|
|
531
|
+
* comparator cost. `affordances` is a single object `ThreadTranscript` re-resolves only when its
|
|
532
|
+
* OWN fields change (see `resolvedAffordances` below) — comparing it by reference here is what lets
|
|
533
|
+
* a consumer's fresh-object-literal `affordances` prop NOT force every block to re-render. */
|
|
373
534
|
function areMessageBlockPropsEqual(prev: MessageBlockProps, next: MessageBlockProps): boolean {
|
|
374
535
|
return (
|
|
375
536
|
prev.message === next.message &&
|
|
376
537
|
prev.streaming === next.streaming &&
|
|
377
538
|
prev.renderers === next.renderers &&
|
|
378
|
-
prev.fallbackRenderer === next.fallbackRenderer
|
|
539
|
+
prev.fallbackRenderer === next.fallbackRenderer &&
|
|
540
|
+
prev.grouped === next.grouped &&
|
|
541
|
+
prev.affordances === next.affordances &&
|
|
542
|
+
prev.isLastAssistant === next.isLastAssistant
|
|
379
543
|
)
|
|
380
544
|
}
|
|
381
545
|
|
|
382
546
|
const MessageBlock = memo(MessageBlockImpl, areMessageBlockPropsEqual)
|
|
383
547
|
|
|
548
|
+
// ── Virtualization (AGENT-CHAT-SPEC.md §9) ─────────────────────────────────────
|
|
549
|
+
//
|
|
550
|
+
// `@tanstack/react-virtual` is an OPTIONAL peer (see `./virtualize`'s module doc). `agent-chat` is
|
|
551
|
+
// ONE package.json export subpath — unlike `basalt-ui/data/virtual`, which gets to statically
|
|
552
|
+
// import the peer because it lives behind its OWN subpath (a consumer who never imports it never
|
|
553
|
+
// resolves the peer either) — so a static top-level import here would make every `ThreadTranscript`
|
|
554
|
+
// consumer require the peer, even ones that never pass `virtualize`. Lazy + dynamic `import()` +
|
|
555
|
+
// `.catch()` degrade, mirroring `../agent/stick-to-bottom.tsx`'s established pattern exactly, keeps
|
|
556
|
+
// the non-virtualized path working with the package absent.
|
|
557
|
+
|
|
558
|
+
/** Default extra rows rendered beyond the viewport, each side. Higher than
|
|
559
|
+
* `BasaltVirtualList`'s generic-list default (5) — a streaming transcript's scroll bursts benefit
|
|
560
|
+
* from more pre-rendered neighbours above/below the fold. */
|
|
561
|
+
const DEFAULT_VIRTUALIZE_OVERSCAN = 6
|
|
562
|
+
|
|
563
|
+
/** Default estimated row height in px, used before a row has been measured. Chat rows run taller
|
|
564
|
+
* than a generic list row (role header + multi-line prose), hence higher than
|
|
565
|
+
* `BasaltVirtualList`'s default of 40. Measured mean row height on the shipped 500-message demo
|
|
566
|
+
* thread is ~145px (total size climbed 49,486 → 72,448px over one descent, ~934px/screenful) — an
|
|
567
|
+
* estimate below the mean shrinks the scrollbar thumb continuously for the whole first scroll
|
|
568
|
+
* down. TanStack's own guidance for dynamically measured lists is to estimate toward the LARGER
|
|
569
|
+
* end: overestimating settles the thumb downward once real measurements land, instead of
|
|
570
|
+
* shrinking it. 160 rounds up from the measured mean with headroom for the estimate to still be
|
|
571
|
+
* defensible on a shorter/plainer demo thread. */
|
|
572
|
+
const DEFAULT_VIRTUALIZE_ESTIMATE_SIZE = 160
|
|
573
|
+
|
|
574
|
+
/** Default `scrollEndThreshold` — how close (px) to the end still counts as "at the end" for
|
|
575
|
+
* `followOnAppend`/`anchorTo: 'end'` to keep tracking rather than freezing the scroll position. */
|
|
576
|
+
const DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD = 64
|
|
577
|
+
|
|
578
|
+
/** Default `VirtualizeOptions.initialScroll` — see `./virtualize`'s fourth composition rule for
|
|
579
|
+
* why a chat transcript opens at the newest message rather than the oldest. */
|
|
580
|
+
const DEFAULT_VIRTUALIZE_INITIAL_SCROLL: NonNullable<VirtualizeOptions['initialScroll']> = 'end'
|
|
581
|
+
|
|
582
|
+
/** Caps `applyInitialScroll`'s clobber-recovery retries (see `./virtualize`'s fourth composition
|
|
583
|
+
* rule for the full mechanism this guards). A small fixed budget, not a time/frame-based deadline:
|
|
584
|
+
* every observed case of virtual-core's `_willUpdate` clobbering this effect's jump resolves within
|
|
585
|
+
* one extra commit, so this exists purely as a backstop against a pathological DOM that never
|
|
586
|
+
* reports settled — not as the expected path. Once exhausted, the effect gives up and marks the
|
|
587
|
+
* jump permanently done wherever the last attempt landed, rather than fighting the DOM (or a user
|
|
588
|
+
* who has since scrolled away) forever. Exported (like `messageBlockRenderCounter`) purely so its
|
|
589
|
+
* test can assert the exact bound without duplicating the number; not part of the public surface. */
|
|
590
|
+
export const MAX_INITIAL_SCROLL_ATTEMPTS = 5
|
|
591
|
+
|
|
592
|
+
type TranscriptRow = {
|
|
593
|
+
readonly key: string
|
|
594
|
+
readonly node: JSX.Element
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
type VirtualizedTranscriptProps = {
|
|
598
|
+
readonly rows: readonly TranscriptRow[]
|
|
599
|
+
readonly height: number | string
|
|
600
|
+
readonly overscan: number
|
|
601
|
+
readonly estimateSize: number
|
|
602
|
+
readonly initialScroll: NonNullable<VirtualizeOptions['initialScroll']>
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The degrade target: every row rendered unwindowed inside a fixed-height scroll container.
|
|
607
|
+
* This is what a `virtualize: true` transcript falls back to when `@tanstack/react-virtual` fails
|
|
608
|
+
* to resolve (peer absent) — same visual shape (a scrollable `height`-bound pane), just without
|
|
609
|
+
* windowing.
|
|
610
|
+
*
|
|
611
|
+
* NOT used as the `Suspense` fallback (see `VirtualizeSuspenseFallback` below) — mounting every
|
|
612
|
+
* real `MessageBlock` subtree for the one tick the lazy import takes to settle would make that
|
|
613
|
+
* settle a genuine unmount/remount of every rendered row, re-firing each row's effects once and
|
|
614
|
+
* risking hitting a message mid-stream during the resolve window.
|
|
615
|
+
*/
|
|
616
|
+
function NonVirtualizedRows({ rows, height }: VirtualizedTranscriptProps): JSX.Element {
|
|
617
|
+
return (
|
|
618
|
+
// theme-allow — degrade target owns its own scroll node, same as the real virtualizer below.
|
|
619
|
+
<Box style={{ height, overflow: 'auto' }}>
|
|
620
|
+
<Stack gap="sm">
|
|
621
|
+
{rows.map((row) => (
|
|
622
|
+
<Fragment key={row.key}>{row.node}</Fragment>
|
|
623
|
+
))}
|
|
624
|
+
</Stack>
|
|
625
|
+
</Box>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Test-only escape hatch (mirrors `messageBlockRenderCounter` above) — proves the peer-absent
|
|
630
|
+
* degrade target itself renders every row without windowing and without throwing, without having
|
|
631
|
+
* to fight `React.lazy`'s permanent once-per-module-instance resolution cache to simulate the
|
|
632
|
+
* peer's absence at the `import()` layer (see thread-message.test.tsx's virtualization describe
|
|
633
|
+
* block for why that approach was rejected). Not exported from `agent-chat/index.ts`. */
|
|
634
|
+
export const nonVirtualizedRowsFallback = NonVirtualizedRows
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Minimal structural slice of `Virtualizer` (virtual-core 3.17.1) that
|
|
638
|
+
* {@link resolveGuardedMeasurement} needs — `indexFromElement`/`itemSizeCache`/`options` are all
|
|
639
|
+
* public instance members, but typing against the real (generic, peer-only) `Virtualizer` class
|
|
640
|
+
* would force this test-only-exported helper to import the peer's runtime types at module scope,
|
|
641
|
+
* the exact static-import-forces-the-peer problem the surrounding `lazy(() => import(...))` exists
|
|
642
|
+
* to avoid. A structural type sidesteps that without loosening to `any`. Generic over the item
|
|
643
|
+
* element type (rather than fixed at `Element`) so it stays exactly assignable FROM a real
|
|
644
|
+
* `Virtualizer<TScrollElement, TItemElement>` at the call site below — `indexFromElement` is
|
|
645
|
+
* declared with arrow-property (not method) syntax upstream, so it is checked contravariantly, and
|
|
646
|
+
* a fixed `Element` parameter there does not structurally match a real instance's narrower
|
|
647
|
+
* `TItemElement` parameter.
|
|
648
|
+
*/
|
|
649
|
+
type MeasurementCacheHost<TItemElement extends Element> = {
|
|
650
|
+
readonly indexFromElement: (node: TItemElement) => number
|
|
651
|
+
readonly itemSizeCache: Map<unknown, number>
|
|
652
|
+
readonly options: {
|
|
653
|
+
readonly getItemKey: (index: number) => unknown
|
|
654
|
+
readonly estimateSize: (index: number) => number
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Resolves the size a `measureElement` implementation should report, given what the underlying
|
|
660
|
+
* (default or custom) measurement already produced: that value unchanged when it is a genuine
|
|
661
|
+
* measurement, or the row's own last-known size when it is `0` — never a real reading for a mounted
|
|
662
|
+
* row, so a `0` only ever means the ResizeObserver measured through a `display: none` (or otherwise
|
|
663
|
+
* unlaid-out) ancestor, e.g. a virtualized transcript sitting inside a collapsed `ThreadFeedRow`.
|
|
664
|
+
* Falling through to it unguarded is what corrupted `itemSizeCache`/`measurementsCache`: virtual-
|
|
665
|
+
* core's default `resizeItem` writes whatever `measureElement` returns with no floor, so every
|
|
666
|
+
* mounted row remeasures at 0px the instant an ancestor hides, `getTotalSize()` collapses within
|
|
667
|
+
* the same tick, and the collapse is never undone — a later real remeasurement only OVERWRITES
|
|
668
|
+
* entries the ResizeObserver happens to re-fire for, it does not restore ones that silently kept
|
|
669
|
+
* their poisoned 0 (see `virtualize.ts`'s composition-rule doc for the full mechanism and the
|
|
670
|
+
* measured numbers).
|
|
671
|
+
*
|
|
672
|
+
* Takes the already-computed `size` plus the {@link MeasurementCacheHost} slice, rather than
|
|
673
|
+
* wrapping the `measureElement` function itself, so this stays a plain, non-generic-over-a-
|
|
674
|
+
* generic-function call at its one call site below (`instance` there is `Virtualizer<TScrollElement,
|
|
675
|
+
* TItemElement>` for THAT call's concrete type arguments, not the peer's own generic
|
|
676
|
+
* `measureElement` export — wrapping the latter directly hits TS's higher-rank inference limits on
|
|
677
|
+
* a doubly-generic HOF). That shape is also what makes this unit-testable with a fake host object
|
|
678
|
+
* and no ResizeObserver/layout engine at all (happy-dom has neither). Not exported from
|
|
679
|
+
* `agent-chat/index.ts`: a test-only escape hatch, matching
|
|
680
|
+
* `messageBlockRenderCounter`/`nonVirtualizedRowsFallback` above.
|
|
681
|
+
*/
|
|
682
|
+
export function resolveGuardedMeasurement<TItemElement extends Element>(
|
|
683
|
+
host: MeasurementCacheHost<TItemElement>,
|
|
684
|
+
element: TItemElement,
|
|
685
|
+
size: number,
|
|
686
|
+
): number {
|
|
687
|
+
if (size > 0) return size
|
|
688
|
+
// Same key derivation `resizeItem` itself uses (index → getItemKey), read BEFORE `resizeItem`
|
|
689
|
+
// writes the new (bad) size — `itemSizeCache` still holds the last-good measurement here.
|
|
690
|
+
const index = host.indexFromElement(element)
|
|
691
|
+
const key = host.options.getItemKey(index)
|
|
692
|
+
return host.itemSizeCache.get(key) ?? host.options.estimateSize(index)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Decides what `VirtualizedRowsInner`'s one-shot initial-scroll effect should do this render,
|
|
697
|
+
* given the inputs the effect closes over. Pure and peer/DOM-free — pulled out of the lazy-loaded
|
|
698
|
+
* closure for the same reason `resolveGuardedMeasurement` is: it stays unit-testable without a
|
|
699
|
+
* real `Virtualizer` instance or `@tanstack/react-virtual` itself.
|
|
700
|
+
*
|
|
701
|
+
* `'start'` and an empty transcript resolve to `'skip-done'` — there is nothing to scroll to, and
|
|
702
|
+
* DONE is permanent (see `virtualize.ts`'s fourth composition rule: the real scroll must fire at
|
|
703
|
+
* most once, ever, so a later append never yanks a user who has scrolled up). A non-positive
|
|
704
|
+
* `containerHeight` — the transcript's own scroll node measuring `0`, e.g. mounted behind a
|
|
705
|
+
* `display: none` ancestor per `virtualize.ts`'s third composition rule — resolves to
|
|
706
|
+
* `'skip-retry'`: NOT done, so the caller's effect (which re-runs after EVERY commit until the jump
|
|
707
|
+
* lands — see {@link applyInitialScroll}) gets another chance the next time this subtree renders,
|
|
708
|
+
* instead of permanently stranding the transcript at the top the moment it becomes visible. Only a
|
|
709
|
+
* genuinely measured, non-empty, `'end'`-targeted mount resolves to `'scroll'`.
|
|
710
|
+
*/
|
|
711
|
+
export function resolveInitialScrollAction(
|
|
712
|
+
initialScroll: NonNullable<VirtualizeOptions['initialScroll']>,
|
|
713
|
+
rowCount: number,
|
|
714
|
+
containerHeight: number,
|
|
715
|
+
): 'scroll' | 'skip-done' | 'skip-retry' {
|
|
716
|
+
if (initialScroll === 'start') return 'skip-done'
|
|
717
|
+
if (rowCount === 0) return 'skip-done'
|
|
718
|
+
if (containerHeight <= 0) return 'skip-retry'
|
|
719
|
+
return 'scroll'
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** The mutable half of the one-shot initial-scroll contract. `hasApplied` is whether it has
|
|
723
|
+
* PERMANENTLY finished — either there was nothing to do (`'start'`/empty transcript), or
|
|
724
|
+
* `scrollToEnd` fired and was subsequently CONFIRMED (via a real DOM read — see
|
|
725
|
+
* {@link applyInitialScroll}'s doc for why virtual-core's own tracked offset can't be trusted for
|
|
726
|
+
* this) to have survived whatever else committed since. `attempts` counts how many times
|
|
727
|
+
* `scrollToEnd` has been (re-)fired this mount; 0 means never attempted, and also gates whether a
|
|
728
|
+
* call re-derives {@link resolveInitialScrollAction} at all. One instance lives for the lifetime of
|
|
729
|
+
* a `VirtualizedRowsInner` mount, held in a `useRef` so it survives re-renders without itself
|
|
730
|
+
* triggering one. */
|
|
731
|
+
export type InitialScrollState = { hasApplied: boolean; attempts: number }
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The stateful half of "fires once, on first mount, and never again" (`virtualize.ts`'s fourth
|
|
735
|
+
* composition rule): wraps {@link resolveInitialScrollAction}'s per-call decision with the
|
|
736
|
+
* ACROSS-CALLS memory that decision alone can't carry. Exported (like `resolveGuardedMeasurement`)
|
|
737
|
+
* so `VirtualizedRowsInner`'s effect below calls this SAME function rather than a hand-mirrored
|
|
738
|
+
* copy for tests — a fake `scrollToEnd` is the only DOM/peer-free way to prove the guard, since a
|
|
739
|
+
* real `Virtualizer`'s own `followOnAppend` also calls the real DOM `scrollTo` on a later append,
|
|
740
|
+
* making that specific call indistinguishable from this one at the DOM boundary (see this
|
|
741
|
+
* function's test suite for the measurement that ruled that boundary out).
|
|
742
|
+
*
|
|
743
|
+
* Firing `scrollToEnd()` once is NOT the same as landing there: virtual-core 3.17.1's own
|
|
744
|
+
* `_willUpdate` (verified against the installed dist) writes to the SAME scroll container from
|
|
745
|
+
* `this.scrollOffset`, which updates only from the ASYNCHRONOUS native `scroll` event — never from
|
|
746
|
+
* the synchronous call that requested a scroll. A commit that runs `_willUpdate` before this
|
|
747
|
+
* effect gets a turn (element-attach) or that re-derives an anchor while `scrollOffset` still holds
|
|
748
|
+
* the pre-jump value writes that STALE offset straight back to `scrollTop`, undoing the jump within
|
|
749
|
+
* about one extra commit of it landing (see `./virtualize`'s fourth composition rule for the full
|
|
750
|
+
* trace this was diagnosed from). So `resolveInitialScrollAction`'s `'scroll'` outcome does not, by
|
|
751
|
+
* itself, set `hasApplied`: `isAtEnd` — a real `scrollTop`/`scrollHeight`/`clientHeight` read, the
|
|
752
|
+
* one piece of ground truth that is NEVER stale, unlike virtual-core's own tracked offset — is
|
|
753
|
+
* consulted on the NEXT call (i.e. the next commit, exactly the cadence this dependency-free effect
|
|
754
|
+
* already runs on) to confirm the jump survived. If it didn't, `scrollToEnd` fires again; if it did,
|
|
755
|
+
* `hasApplied` is set and the effect is inert for the rest of the mount. `MAX_INITIAL_SCROLL_ATTEMPTS`
|
|
756
|
+
* bounds this so a DOM that pathologically never reports settled — or a user who scrolls away mid-
|
|
757
|
+
* measurement — can never be fought forever; once exhausted, `hasApplied` is set regardless, wherever
|
|
758
|
+
* the last attempt left the container.
|
|
759
|
+
*
|
|
760
|
+
* `state.hasApplied` is the ONLY thing that can permanently stop this from calling `scrollToEnd`
|
|
761
|
+
* again: `'start'` and an empty transcript set it immediately (nothing to verify), and `'skip-retry'`
|
|
762
|
+
* deliberately leaves it `false` so a transcript that mounted behind a hidden ancestor gets another
|
|
763
|
+
* attempt on the caller's next commit, rather than being silently abandoned the moment it becomes
|
|
764
|
+
* visible. That retry — and the clobber-recovery above — is why the caller's effect carries NO
|
|
765
|
+
* dependency array: the commit that matters (a collapsed `ThreadFeedRow` flipping its body back to
|
|
766
|
+
* `display: block`, or virtual-core's own follow-up commit after this effect's own jump) changes
|
|
767
|
+
* neither the row count nor the virtualizer identity, so a dependency-keyed effect would never
|
|
768
|
+
* re-run for it.
|
|
769
|
+
*
|
|
770
|
+
* `getContainerHeight` is a THUNK, not a number, for the same reason: reading `offsetHeight` forces
|
|
771
|
+
* a synchronous layout, and it is read at most ONCE per mount — only on the very first call
|
|
772
|
+
* (`attempts === 0`) — never again while verifying or retrying, so a landed transcript still pays
|
|
773
|
+
* nothing for the effect it keeps re-running (a streaming transcript commits on every chunk).
|
|
774
|
+
* `isAtEnd` is cheaper (a plain property read, no forced reflow beyond what the browser already
|
|
775
|
+
* tracks) and is exactly what the verification/retry loop above consults instead.
|
|
776
|
+
*/
|
|
777
|
+
export function applyInitialScroll(
|
|
778
|
+
state: InitialScrollState,
|
|
779
|
+
initialScroll: NonNullable<VirtualizeOptions['initialScroll']>,
|
|
780
|
+
rowCount: number,
|
|
781
|
+
getContainerHeight: () => number,
|
|
782
|
+
scrollToEnd: () => void,
|
|
783
|
+
isAtEnd: () => boolean,
|
|
784
|
+
): void {
|
|
785
|
+
if (state.hasApplied) return
|
|
786
|
+
|
|
787
|
+
// Already fired at least once this mount — verify it survived rather than re-deriving
|
|
788
|
+
// `resolveInitialScrollAction` (row count / container height are irrelevant to "did the DOM
|
|
789
|
+
// keep the scroll we already decided on").
|
|
790
|
+
if (state.attempts > 0) {
|
|
791
|
+
if (isAtEnd() || state.attempts >= MAX_INITIAL_SCROLL_ATTEMPTS) {
|
|
792
|
+
state.hasApplied = true
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
scrollToEnd()
|
|
796
|
+
state.attempts += 1
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const action = resolveInitialScrollAction(initialScroll, rowCount, getContainerHeight())
|
|
801
|
+
if (action === 'skip-retry') return
|
|
802
|
+
if (action === 'skip-done') {
|
|
803
|
+
state.hasApplied = true
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
scrollToEnd()
|
|
807
|
+
state.attempts += 1
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Reads the transcript's own scroll container to answer "is it currently at (or within
|
|
811
|
+
* `threshold`px of) the end" — the ground-truth check {@link applyInitialScroll} verifies its jump
|
|
812
|
+
* against. Deliberately NOT `virtualizer.isAtEnd()`/`getScrollOffset()`: both are backed by
|
|
813
|
+
* `this.scrollOffset`, the exact value that lags a real scroll until the next native `scroll`
|
|
814
|
+
* event fires — using it here would make the verification vulnerable to the identical staleness
|
|
815
|
+
* that causes the clobber in the first place. `scrollTop`/`scrollHeight`/`clientHeight` are plain
|
|
816
|
+
* DOM properties with no such lag: `Element.scrollTo` with `'auto'` (this module's own behavior)
|
|
817
|
+
* updates `scrollTop` synchronously, so a read immediately after — or, as here, on the next
|
|
818
|
+
* commit — reflects reality. `null` (container not yet attached) reads as "not at end" rather than
|
|
819
|
+
* throwing, matching every other guard in this file's tolerance for a not-yet-mounted ref. */
|
|
820
|
+
function isContainerAtEnd(container: HTMLDivElement | null, threshold: number): boolean {
|
|
821
|
+
if (container === null) return false
|
|
822
|
+
const distanceFromEnd = container.scrollHeight - container.clientHeight - container.scrollTop
|
|
823
|
+
return distanceFromEnd <= threshold
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Lazy-loaded real virtualizer. Resolves once, permanently, for the lifetime of this module
|
|
827
|
+
* instance — identical caching behaviour to `../agent/stick-to-bottom.tsx`'s `LazyStickToBottom`. */
|
|
828
|
+
const LazyVirtualizedRows = lazy(() =>
|
|
829
|
+
import('@tanstack/react-virtual')
|
|
830
|
+
.then(({ useVirtualizer, measureElement: defaultMeasureElement }) => {
|
|
831
|
+
function VirtualizedRowsInner({
|
|
832
|
+
rows,
|
|
833
|
+
height,
|
|
834
|
+
overscan,
|
|
835
|
+
estimateSize,
|
|
836
|
+
initialScroll,
|
|
837
|
+
}: VirtualizedTranscriptProps): JSX.Element {
|
|
838
|
+
const parentRef = useRef<HTMLDivElement>(null)
|
|
839
|
+
const initialScrollStateRef = useRef<InitialScrollState>({
|
|
840
|
+
hasApplied: false,
|
|
841
|
+
attempts: 0,
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
// Resolved research facts (virtual-core 3.17.1, carried by react-virtual 3.14.3):
|
|
845
|
+
// `getItemKey` returning `message.id` (NOT the default index) so prepending/streaming
|
|
846
|
+
// doesn't scramble anchoring; `anchorTo: 'end'` + `followOnAppend` for chat-scroll rhythm;
|
|
847
|
+
// `useFlushSync: false` per the same React-19 opt-out `BasaltVirtualList` documents.
|
|
848
|
+
const virtualizer = useVirtualizer({
|
|
849
|
+
count: rows.length,
|
|
850
|
+
getScrollElement: () => parentRef.current,
|
|
851
|
+
estimateSize: () => estimateSize,
|
|
852
|
+
overscan,
|
|
853
|
+
getItemKey: (index) => rows[index]?.key ?? index,
|
|
854
|
+
anchorTo: 'end',
|
|
855
|
+
followOnAppend: true,
|
|
856
|
+
scrollEndThreshold: DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD,
|
|
857
|
+
useFlushSync: false,
|
|
858
|
+
// Guards against the hidden-ancestor cache-poisoning mechanism
|
|
859
|
+
// `resolveGuardedMeasurement`'s doc and `virtualize.ts`'s composition rules describe —
|
|
860
|
+
// NOT `enabled: false`, which virtual-core 3.17.1 wires to WIPE
|
|
861
|
+
// `itemSizeCache`/`measurementsCache` entirely (`getMeasurements`'s `!enabled` branch)
|
|
862
|
+
// rather than pause measurement, so toggling it off/on around a collapse would be
|
|
863
|
+
// strictly worse than the bug it was meant to fix.
|
|
864
|
+
measureElement: (element, entry, instance) =>
|
|
865
|
+
resolveGuardedMeasurement(
|
|
866
|
+
instance,
|
|
867
|
+
element,
|
|
868
|
+
defaultMeasureElement(element, entry, instance),
|
|
869
|
+
),
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
// The one-shot "open at the newest message" jump (`virtualize.ts`'s fourth composition
|
|
873
|
+
// rule) — see `applyInitialScroll`'s doc for the guard mechanics, including why this effect
|
|
874
|
+
// deliberately carries NO dependency array. Short version: this subtree can mount while
|
|
875
|
+
// INVISIBLE (the lazy `import()` above settles asynchronously, so a `ThreadFeedRow`
|
|
876
|
+
// collapsed during that window mounts it behind `display: none`), and the commit that makes
|
|
877
|
+
// it visible again changes no dependency a keyed array could watch. That SAME dependency-
|
|
878
|
+
// free cadence is also what lets this effect recover from virtual-core's own `_willUpdate`
|
|
879
|
+
// clobbering the jump on the commit right after it lands (see `applyInitialScroll`'s doc) —
|
|
880
|
+
// `state.hasApplied` only becomes permanent once `isContainerAtEnd` confirms it on a later
|
|
881
|
+
// call, not the instant `scrollToEnd` is invoked. `parentRef.current.offsetHeight` (not
|
|
882
|
+
// `getBoundingClientRect`) matches what virtual-core's own `getRect` reads for the scroll
|
|
883
|
+
// container, so a hidden ancestor is treated as unmeasured in exactly the cases virtual-core
|
|
884
|
+
// itself would also treat that way; it is passed as a thunk (like the `isContainerAtEnd`
|
|
885
|
+
// read below) so nothing is forced once the jump has landed and been confirmed.
|
|
886
|
+
useLayoutEffect(() => {
|
|
887
|
+
applyInitialScroll(
|
|
888
|
+
initialScrollStateRef.current,
|
|
889
|
+
initialScroll,
|
|
890
|
+
rows.length,
|
|
891
|
+
() => parentRef.current?.offsetHeight ?? 0,
|
|
892
|
+
() => virtualizer.scrollToEnd({ behavior: 'auto' }),
|
|
893
|
+
() => isContainerAtEnd(parentRef.current, DEFAULT_VIRTUALIZE_SCROLL_END_THRESHOLD),
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
return (
|
|
898
|
+
// theme-allow — TanStack Virtual measures/scrolls this element (never nest in BasaltStickToBottom, see ./virtualize).
|
|
899
|
+
<Box ref={parentRef} style={{ height, overflow: 'auto' }}>
|
|
900
|
+
<Box style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
|
901
|
+
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
902
|
+
const row = rows[virtualItem.index]
|
|
903
|
+
if (row === undefined) return null
|
|
904
|
+
return (
|
|
905
|
+
<Box
|
|
906
|
+
key={row.key}
|
|
907
|
+
// Dynamic measurement of variable-height rows (messages vary a lot: a
|
|
908
|
+
// one-line reply vs. a long streamed answer with code fences) — both required
|
|
909
|
+
// together, per the resolved research facts.
|
|
910
|
+
ref={virtualizer.measureElement}
|
|
911
|
+
data-index={virtualItem.index}
|
|
912
|
+
style={{
|
|
913
|
+
position: 'absolute',
|
|
914
|
+
top: 0,
|
|
915
|
+
left: 0,
|
|
916
|
+
width: '100%',
|
|
917
|
+
transform: `translateY(${virtualItem.start}px)`,
|
|
918
|
+
}}
|
|
919
|
+
>
|
|
920
|
+
{row.node}
|
|
921
|
+
</Box>
|
|
922
|
+
)
|
|
923
|
+
})}
|
|
924
|
+
</Box>
|
|
925
|
+
</Box>
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
return { default: VirtualizedRowsInner }
|
|
929
|
+
})
|
|
930
|
+
.catch(() => ({ default: NonVirtualizedRows })),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Lightweight `Suspense` fallback for `VirtualizedTranscript` — shown only for the one tick the
|
|
935
|
+
* dynamic `@tanstack/react-virtual` import takes to resolve. Deliberately NOT `NonVirtualizedRows`:
|
|
936
|
+
* see that component's doc for why mounting every real row here would be a genuine (if one-time)
|
|
937
|
+
* unmount/remount of the whole transcript. An empty, correctly-sized scroll pane avoids a layout
|
|
938
|
+
* flash without paying that cost — it settles state before any row has meaningfully mounted.
|
|
939
|
+
*/
|
|
940
|
+
function VirtualizeSuspenseFallback({ height }: { readonly height: number | string }): JSX.Element {
|
|
941
|
+
// theme-allow — placeholder owns its own scroll node, matching the real virtualizer's shape.
|
|
942
|
+
return <Box style={{ height, overflow: 'auto' }} />
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function VirtualizedTranscript(props: VirtualizedTranscriptProps): JSX.Element {
|
|
946
|
+
return (
|
|
947
|
+
<Suspense fallback={<VirtualizeSuspenseFallback height={props.height} />}>
|
|
948
|
+
<LazyVirtualizedRows {...props} />
|
|
949
|
+
</Suspense>
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
|
|
384
953
|
// ── ThreadTranscript ──────────────────────────────────────────────────────────
|
|
385
954
|
|
|
386
955
|
const EMPTY_RENDERERS: PartRenderers = {}
|
|
@@ -397,9 +966,58 @@ type ThreadTranscriptBase = {
|
|
|
397
966
|
/** Called for a part whose type is neither an AgentPart variant nor a registered key. Defaults
|
|
398
967
|
* to a visible `UnknownPartChip` outside production, `null` in production — never throws. */
|
|
399
968
|
readonly fallbackRenderer?: PartRenderer<ForeignPart>
|
|
969
|
+
/** Per-message hover-row affordances (timestamp/copy/regenerate/custom actions). Unset fields
|
|
970
|
+
* fall back to `DEFAULT_AFFORDANCES`. Never shown on the live/streaming message — see
|
|
971
|
+
* `MessageBlockProps.affordances`. */
|
|
972
|
+
readonly affordances?: MessageAffordances
|
|
973
|
+
/** Suppresses the role label and surface chrome on a message whose predecessor shares its role
|
|
974
|
+
* and landed within 5 minutes — the Slack rhythm.
|
|
975
|
+
* @default true */
|
|
976
|
+
readonly groupConsecutive?: boolean
|
|
400
977
|
}
|
|
401
978
|
|
|
402
|
-
export type ThreadTranscriptProps = ThreadTranscriptBase
|
|
979
|
+
export type ThreadTranscriptProps = ThreadTranscriptBase & VirtualizeProps
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Wraps a possibly-fresh-every-render EVENT HANDLER in a wrapper function whose IDENTITY never
|
|
983
|
+
* changes across renders (while it is defined), always calling through to the LATEST version via a
|
|
984
|
+
* ref — the same "mirror the latest value every render" idiom `use-agent-thread-runs.ts` uses for
|
|
985
|
+
* `storeRef`/`transportRef`, applied to a callback instead of a value.
|
|
986
|
+
*
|
|
987
|
+
* This is what lets `resolvedAffordances` below key its memo on the handler's PRESENCE (defined vs
|
|
988
|
+
* not) rather than on ITS reference — a consumer passing a fresh inline `onRegenerate={(id) => …}`
|
|
989
|
+
* literal every render (the common case; see `MessageAffordances`'s own doc) no longer defeats
|
|
990
|
+
* `resolvedAffordances`'s memo, and transitively `MessageBlock`'s `affordances`-by-reference
|
|
991
|
+
* bail-out. Only a genuine defined-to-undefined (or back) transition changes the returned wrapper's
|
|
992
|
+
* presence.
|
|
993
|
+
*
|
|
994
|
+
* ONLY EVER APPLY THIS TO AN EVENT HANDLER — never to a render prop. The trick works precisely
|
|
995
|
+
* because the wrapper is invoked AFTER render (from a click), so freezing its identity costs
|
|
996
|
+
* nothing: React re-reads the ref at call time. A render prop (`MessageAffordances.actions`, which
|
|
997
|
+
* `MessageAffordanceRow` invokes DURING render to produce nodes) is the opposite case: its identity
|
|
998
|
+
* is the only signal React has that its OUTPUT might differ, so freezing it makes every memo above
|
|
999
|
+
* it bail out and the consumer's actions render permanently stale — a `pin`/`star` control wired to
|
|
1000
|
+
* consumer state would never update again for the transcript's whole lifetime. `actions` is
|
|
1001
|
+
* therefore deliberately keyed by reference in `resolvedAffordances`; a consumer that wants the
|
|
1002
|
+
* memo win there wraps its own `actions` in `useCallback`, which is a choice only the consumer can
|
|
1003
|
+
* make because only it knows what the closure reads.
|
|
1004
|
+
*/
|
|
1005
|
+
function useStableCallback<Args extends unknown[], R>(
|
|
1006
|
+
callback: ((...args: Args) => R) | undefined,
|
|
1007
|
+
): ((...args: Args) => R) | undefined {
|
|
1008
|
+
const callbackRef = useRef(callback)
|
|
1009
|
+
callbackRef.current = callback
|
|
1010
|
+
// Deliberately empty deps: this is what keeps `stable`'s identity constant across renders. It
|
|
1011
|
+
// reads `callbackRef.current` at CALL time (not at creation time), so it always invokes whatever
|
|
1012
|
+
// the consumer most recently passed — never a closure captured at first mount.
|
|
1013
|
+
const stable = useMemo<(...args: Args) => R>(
|
|
1014
|
+
() =>
|
|
1015
|
+
(...args: Args) =>
|
|
1016
|
+
callbackRef.current?.(...args) as R,
|
|
1017
|
+
[],
|
|
1018
|
+
)
|
|
1019
|
+
return callback === undefined ? undefined : stable
|
|
1020
|
+
}
|
|
403
1021
|
|
|
404
1022
|
/**
|
|
405
1023
|
* Renders a thread's settled messages, each role-labelled ("You" / "Assistant"). Each part
|
|
@@ -407,18 +1025,35 @@ export type ThreadTranscriptProps = ThreadTranscriptBase
|
|
|
407
1025
|
* the built-in six, then `fallbackRenderer`. When `liveParts` is non-empty, an extra in-progress
|
|
408
1026
|
* assistant block is appended at the tail.
|
|
409
1027
|
*
|
|
1028
|
+
* Set `virtualize` (with a required `height`) to window a long thread over
|
|
1029
|
+
* `@tanstack/react-virtual` (an optional peer) instead of rendering every message. The virtualized
|
|
1030
|
+
* transcript owns its own fixed-height scroll container and must NOT be nested inside
|
|
1031
|
+
* `BasaltStickToBottom` — see `./virtualize`'s module doc. With the peer absent, `virtualize` still
|
|
1032
|
+
* renders (unwindowed, inside the same fixed-height pane) rather than throwing. A virtualized
|
|
1033
|
+
* transcript scrolls itself to the newest message once, on mount (`initialScroll: 'start'` opts
|
|
1034
|
+
* out) — see `./virtualize`'s fourth composition rule.
|
|
1035
|
+
*
|
|
410
1036
|
* @example
|
|
411
1037
|
* import { ThreadTranscript } from 'basalt-ui'
|
|
412
1038
|
* <ThreadTranscript messages={thread.messages} liveParts={liveParts} liveStatus={runStatus} />
|
|
1039
|
+
*
|
|
1040
|
+
* @example
|
|
1041
|
+
* // Windowed, for a long thread:
|
|
1042
|
+
* <ThreadTranscript messages={thread.messages} virtualize height={480} />
|
|
413
1043
|
*/
|
|
414
|
-
export function ThreadTranscript({
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1044
|
+
export function ThreadTranscript(props: ThreadTranscriptProps): JSX.Element {
|
|
1045
|
+
const {
|
|
1046
|
+
messages,
|
|
1047
|
+
liveParts,
|
|
1048
|
+
liveStatus,
|
|
1049
|
+
renderers: renderersProp,
|
|
1050
|
+
fallbackRenderer = DEFAULT_FALLBACK_RENDERER,
|
|
1051
|
+
affordances: affordancesProp,
|
|
1052
|
+
groupConsecutive = true,
|
|
1053
|
+
} = props
|
|
1054
|
+
const virtualized = resolveVirtualize(props)
|
|
1055
|
+
|
|
1056
|
+
// Mirrors PartList's own `useMemo` (part-list.tsx:290) — avoids rebuilding the renderer
|
|
422
1057
|
// lookup on every streaming re-render.
|
|
423
1058
|
const renderers = useMemo(() => renderersProp ?? EMPTY_RENDERERS, [renderersProp])
|
|
424
1059
|
|
|
@@ -427,17 +1062,85 @@ export function ThreadTranscript({
|
|
|
427
1062
|
return { id: '__live__', role: 'assistant', parts: liveParts as TranscriptPart[], createdAt: 0 }
|
|
428
1063
|
}, [liveParts])
|
|
429
1064
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
1065
|
+
const lastAssistantMessageId = useMemo(() => {
|
|
1066
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1067
|
+
const candidate = messages[i]
|
|
1068
|
+
if (candidate?.role === 'assistant') return candidate.id
|
|
1069
|
+
}
|
|
1070
|
+
return null
|
|
1071
|
+
}, [messages])
|
|
1072
|
+
|
|
1073
|
+
// `onRegenerate` is an EVENT HANDLER, so it goes through `useStableCallback` before this memo
|
|
1074
|
+
// sees it — without that, a consumer's fresh inline `{ onRegenerate: (id) => … }` literal (the
|
|
1075
|
+
// common case) changes every render, so keying the deps array on the raw field defeated this memo
|
|
1076
|
+
// for exactly that case: `resolvedAffordances` (and transitively every `MessageBlock`'s
|
|
1077
|
+
// `affordances` reference-equality bail-out) recomputed every render regardless of the
|
|
1078
|
+
// timestamp/copy fields actually being stable. Keying on the STABLE wrapper's presence fixes
|
|
1079
|
+
// that; only a genuine defined-to-undefined transition on the underlying handler invalidates.
|
|
1080
|
+
//
|
|
1081
|
+
// `actions` is deliberately NOT given the same treatment: it is a RENDER prop (invoked during
|
|
1082
|
+
// render to produce nodes), so its reference is the only signal that its OUTPUT may have changed.
|
|
1083
|
+
// Stabilizing it froze every consumer action at its first-render output for the transcript's
|
|
1084
|
+
// whole lifetime. It is keyed by reference here, which means a fresh inline `actions` literal
|
|
1085
|
+
// legitimately re-renders the blocks — the correct trade, and one a consumer can opt out of with
|
|
1086
|
+
// its own `useCallback`. See `useStableCallback`'s doc.
|
|
1087
|
+
const stableOnRegenerate = useStableCallback(affordancesProp?.onRegenerate)
|
|
1088
|
+
const actions = affordancesProp?.actions
|
|
1089
|
+
|
|
1090
|
+
const resolvedAffordances = useMemo<MessageAffordances>(
|
|
1091
|
+
() => ({
|
|
1092
|
+
timestamp: affordancesProp?.timestamp ?? DEFAULT_AFFORDANCES.timestamp,
|
|
1093
|
+
copy: affordancesProp?.copy ?? DEFAULT_AFFORDANCES.copy,
|
|
1094
|
+
// `exactOptionalPropertyTypes` forbids assigning a possibly-`undefined` value to an optional
|
|
1095
|
+
// property directly — spread each field in only when it is actually present, rather than
|
|
1096
|
+
// widening `MessageAffordances` itself to accept an explicit `undefined`.
|
|
1097
|
+
...(stableOnRegenerate !== undefined && { onRegenerate: stableOnRegenerate }),
|
|
1098
|
+
...(actions !== undefined && { actions }),
|
|
1099
|
+
}),
|
|
1100
|
+
[affordancesProp?.timestamp, affordancesProp?.copy, stableOnRegenerate, actions],
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
// Settled rows are memoized SEPARATELY from the live block: `liveMessage`/`liveStatus` change on
|
|
1104
|
+
// every streamed chunk (a fresh `liveParts` array produces a fresh `liveMessage` object each
|
|
1105
|
+
// time), so keeping them in the SAME memo as the settled rows recomputed every settled message's
|
|
1106
|
+
// JSX descriptor on every chunk — O(n) allocation per token on a long thread, and it handed
|
|
1107
|
+
// `useVirtualizer` a new `rows`/`count`/`getItemKey` identity every chunk too. Appending the live
|
|
1108
|
+
// row OUTSIDE this memo means a streamed chunk only ever re-allocates the one live row plus a
|
|
1109
|
+
// cheap wrapping array — the settled `MessageBlock` elements are the SAME object references
|
|
1110
|
+
// React saw last render, so it bails out of that whole subtree without even reaching each
|
|
1111
|
+
// `MessageBlock`'s memo comparator.
|
|
1112
|
+
const settledRows = useMemo<TranscriptRow[]>(() => {
|
|
1113
|
+
const out: TranscriptRow[] = []
|
|
1114
|
+
messages.forEach((message, index) => {
|
|
1115
|
+
out.push({
|
|
1116
|
+
key: message.id,
|
|
1117
|
+
node: (
|
|
1118
|
+
<MessageBlock
|
|
1119
|
+
message={message}
|
|
1120
|
+
renderers={renderers}
|
|
1121
|
+
fallbackRenderer={fallbackRenderer}
|
|
1122
|
+
grouped={groupConsecutive && isConsecutiveWithPredecessor(message, messages[index - 1])}
|
|
1123
|
+
isLastAssistant={message.id === lastAssistantMessageId}
|
|
1124
|
+
affordances={resolvedAffordances}
|
|
1125
|
+
/>
|
|
1126
|
+
),
|
|
1127
|
+
})
|
|
1128
|
+
})
|
|
1129
|
+
return out
|
|
1130
|
+
}, [
|
|
1131
|
+
messages,
|
|
1132
|
+
renderers,
|
|
1133
|
+
fallbackRenderer,
|
|
1134
|
+
groupConsecutive,
|
|
1135
|
+
lastAssistantMessageId,
|
|
1136
|
+
resolvedAffordances,
|
|
1137
|
+
])
|
|
1138
|
+
|
|
1139
|
+
const liveRow = useMemo<TranscriptRow | null>(() => {
|
|
1140
|
+
if (liveMessage === null) return null
|
|
1141
|
+
return {
|
|
1142
|
+
key: liveMessage.id,
|
|
1143
|
+
node: (
|
|
441
1144
|
<div aria-live="polite" aria-atomic="false" aria-relevant="additions">
|
|
442
1145
|
<MessageBlock
|
|
443
1146
|
message={liveMessage}
|
|
@@ -446,7 +1149,32 @@ export function ThreadTranscript({
|
|
|
446
1149
|
fallbackRenderer={fallbackRenderer}
|
|
447
1150
|
/>
|
|
448
1151
|
</div>
|
|
449
|
-
)
|
|
1152
|
+
),
|
|
1153
|
+
}
|
|
1154
|
+
}, [liveMessage, liveStatus, renderers, fallbackRenderer])
|
|
1155
|
+
|
|
1156
|
+
const rows = useMemo<TranscriptRow[]>(
|
|
1157
|
+
() => (liveRow === null ? settledRows : [...settledRows, liveRow]),
|
|
1158
|
+
[settledRows, liveRow],
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
if (virtualized !== null) {
|
|
1162
|
+
return (
|
|
1163
|
+
<VirtualizedTranscript
|
|
1164
|
+
rows={rows}
|
|
1165
|
+
height={virtualized.height}
|
|
1166
|
+
overscan={virtualized.options.overscan ?? DEFAULT_VIRTUALIZE_OVERSCAN}
|
|
1167
|
+
estimateSize={virtualized.options.estimateSize ?? DEFAULT_VIRTUALIZE_ESTIMATE_SIZE}
|
|
1168
|
+
initialScroll={virtualized.options.initialScroll ?? DEFAULT_VIRTUALIZE_INITIAL_SCROLL}
|
|
1169
|
+
/>
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return (
|
|
1174
|
+
<Stack gap="sm">
|
|
1175
|
+
{rows.map((row) => (
|
|
1176
|
+
<Fragment key={row.key}>{row.node}</Fragment>
|
|
1177
|
+
))}
|
|
450
1178
|
</Stack>
|
|
451
1179
|
)
|
|
452
1180
|
}
|