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.
Files changed (105) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +38 -35
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/configs/oxlint-plugin.js +7 -3
  6. package/configs/oxlint-plugin.test.ts +34 -5
  7. package/configs/oxlint.json +4 -4
  8. package/dist/agent/adapter.d.ts +5 -1
  9. package/dist/agent/adapter.d.ts.map +1 -1
  10. package/dist/agent/adapter.js +22 -13
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +6 -2
  13. package/dist/agent/id.d.ts +67 -19
  14. package/dist/agent/id.d.ts.map +1 -1
  15. package/dist/agent/id.js +21 -7
  16. package/dist/agent/thread.d.ts +4 -0
  17. package/dist/agent/thread.d.ts.map +1 -1
  18. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  19. package/dist/agent/use-agent-stream.js +2 -2
  20. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  21. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  22. package/dist/agent/use-agent-thread-runs.js +55 -15
  23. package/dist/agent-chat/index.d.ts +4 -0
  24. package/dist/agent-chat/index.d.ts.map +1 -1
  25. package/dist/agent-chat/index.js +2 -0
  26. package/dist/agent-chat/message-affordances.d.ts +59 -0
  27. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  28. package/dist/agent-chat/message-affordances.js +7 -0
  29. package/dist/agent-chat/relative-time.d.ts +27 -0
  30. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  31. package/dist/agent-chat/relative-time.js +27 -0
  32. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  33. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  34. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  35. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  36. package/dist/agent-chat/thread-feed-row.js +156 -0
  37. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  38. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  39. package/dist/agent-chat/thread-feed.d.ts +34 -5
  40. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  41. package/dist/agent-chat/thread-feed.js +52 -19
  42. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  43. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  44. package/dist/agent-chat/thread-message.d.ts +183 -2
  45. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  46. package/dist/agent-chat/thread-message.js +315 -34
  47. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  48. package/dist/agent-chat/thread-outcome-card.js +1 -16
  49. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  50. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  51. package/dist/agent-chat/thread-workspace.js +29 -3
  52. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  53. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  54. package/dist/agent-chat/virtualize.d.ts +123 -0
  55. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  56. package/dist/agent-chat/virtualize.js +9 -0
  57. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  58. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  59. package/dist/data/index.d.ts +5 -1
  60. package/dist/data/index.d.ts.map +1 -1
  61. package/dist/data/virtual-list.d.ts.map +1 -1
  62. package/dist/data/virtual.d.ts +2 -1
  63. package/dist/data/virtual.d.ts.map +1 -1
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/surfaces.d.ts +2 -2
  68. package/dist/surfaces.d.ts.map +1 -1
  69. package/dist/surfaces.js +3 -2
  70. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  71. package/llms.txt +6 -6
  72. package/package.json +2 -2
  73. package/src/agent/adapter.test.ts +237 -0
  74. package/src/agent/adapter.ts +72 -14
  75. package/src/agent/ai-sdk-transport.test.ts +64 -0
  76. package/src/agent/ai-sdk-transport.ts +8 -2
  77. package/src/agent/id.test.ts +95 -1
  78. package/src/agent/id.ts +98 -28
  79. package/src/agent/thread.ts +4 -0
  80. package/src/agent/use-agent-stream.test.tsx +22 -0
  81. package/src/agent/use-agent-stream.ts +6 -3
  82. package/src/agent/use-agent-thread-runs.test.tsx +326 -0
  83. package/src/agent/use-agent-thread-runs.ts +159 -23
  84. package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
  85. package/src/agent-chat/index.ts +12 -0
  86. package/src/agent-chat/message-affordances.ts +61 -0
  87. package/src/agent-chat/relative-time.test.ts +95 -0
  88. package/src/agent-chat/relative-time.ts +55 -0
  89. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  90. package/src/agent-chat/thread-feed-row.tsx +274 -0
  91. package/src/agent-chat/thread-feed.test.tsx +241 -0
  92. package/src/agent-chat/thread-feed.tsx +119 -18
  93. package/src/agent-chat/thread-message.test.tsx +937 -3
  94. package/src/agent-chat/thread-message.tsx +766 -38
  95. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  96. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  97. package/src/agent-chat/thread-workspace.tsx +69 -5
  98. package/src/agent-chat/virtualize.ts +132 -0
  99. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  100. package/src/data/index.ts +5 -1
  101. package/src/data/virtual-list.tsx +2 -1
  102. package/src/data/virtual.ts +2 -1
  103. package/src/index.ts +5 -0
  104. package/src/surfaces.ts +2 -1
  105. 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 reply.
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
- backgroundColor: message.role === 'user' ? VX.surface.subtle : VX.surface.panel,
329
- borderRadius: VX.radiusCard,
330
- boxShadow: VX.shadowCard,
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
- <Group gap={6} align="center">
335
- <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[message.role]}</Text>
336
- {streaming && <Loader size="xs" />}
337
- {finishIndicator !== undefined && (
338
- <Text style={{ ...MICRO_LABEL_STYLE, color: finishIndicator.statusToken }}>
339
- {finishIndicator.label}
340
- </Text>
341
- )}
342
- </Group>
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
- messages,
416
- liveParts,
417
- liveStatus,
418
- renderers: renderersProp,
419
- fallbackRenderer = DEFAULT_FALLBACK_RENDERER,
420
- }: ThreadTranscriptProps): JSX.Element {
421
- // Mirrors PartList's own `useMemo` (part-list.tsx:237-240) — avoids rebuilding the renderer
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
- return (
431
- <Stack gap="sm">
432
- {messages.map((message) => (
433
- <MessageBlock
434
- key={message.id}
435
- message={message}
436
- renderers={renderers}
437
- fallbackRenderer={fallbackRenderer}
438
- />
439
- ))}
440
- {liveMessage !== null && (
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
  }