basalt-ui 1.12.0 → 1.14.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 (123) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +24 -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/shell/app-sidebar.d.ts +19 -1
  68. package/dist/shell/app-sidebar.d.ts.map +1 -1
  69. package/dist/shell/app-sidebar.js +30 -26
  70. package/dist/shell/app-sidebar.module.css +11 -0
  71. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  72. package/dist/shell/app-sidebar.test.d.ts +2 -0
  73. package/dist/shell/app-sidebar.test.d.ts.map +1 -0
  74. package/dist/shell/index.d.ts +10 -1
  75. package/dist/shell/index.d.ts.map +1 -1
  76. package/dist/shell/index.js +2 -0
  77. package/dist/shell/index.test.d.ts +2 -0
  78. package/dist/shell/index.test.d.ts.map +1 -0
  79. package/dist/surfaces.d.ts +2 -2
  80. package/dist/surfaces.d.ts.map +1 -1
  81. package/dist/surfaces.js +3 -2
  82. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  83. package/llms.txt +6 -6
  84. package/package.json +2 -2
  85. package/src/agent/adapter.test.ts +237 -0
  86. package/src/agent/adapter.ts +72 -14
  87. package/src/agent/ai-sdk-transport.test.ts +64 -0
  88. package/src/agent/ai-sdk-transport.ts +8 -2
  89. package/src/agent/id.test.ts +95 -1
  90. package/src/agent/id.ts +98 -28
  91. package/src/agent/thread.ts +4 -0
  92. package/src/agent/use-agent-stream.test.tsx +22 -0
  93. package/src/agent/use-agent-stream.ts +6 -3
  94. package/src/agent/use-agent-thread-runs.test.tsx +326 -0
  95. package/src/agent/use-agent-thread-runs.ts +159 -23
  96. package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
  97. package/src/agent-chat/index.ts +12 -0
  98. package/src/agent-chat/message-affordances.ts +61 -0
  99. package/src/agent-chat/relative-time.test.ts +95 -0
  100. package/src/agent-chat/relative-time.ts +55 -0
  101. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  102. package/src/agent-chat/thread-feed-row.tsx +274 -0
  103. package/src/agent-chat/thread-feed.test.tsx +241 -0
  104. package/src/agent-chat/thread-feed.tsx +119 -18
  105. package/src/agent-chat/thread-message.test.tsx +937 -3
  106. package/src/agent-chat/thread-message.tsx +766 -38
  107. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  108. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  109. package/src/agent-chat/thread-workspace.tsx +69 -5
  110. package/src/agent-chat/virtualize.ts +132 -0
  111. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  112. package/src/data/index.ts +5 -1
  113. package/src/data/virtual-list.tsx +2 -1
  114. package/src/data/virtual.ts +2 -1
  115. package/src/index.ts +5 -0
  116. package/src/shell/app-sidebar.module.css +11 -0
  117. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  118. package/src/shell/app-sidebar.test.tsx +98 -0
  119. package/src/shell/app-sidebar.tsx +22 -0
  120. package/src/shell/index.test.tsx +35 -0
  121. package/src/shell/index.tsx +11 -0
  122. package/src/surfaces.ts +2 -1
  123. package/src/theme/shadow-surfaces.test.ts +9 -0
@@ -23,6 +23,7 @@ import { useHover } from '@mantine/hooks'
23
23
  import type { JSX } from 'react'
24
24
  import type { AgentThread, ThreadStatus } from '../agent'
25
25
  import { alpha, VX } from '../tokens'
26
+ import { formatRelativeTime } from './relative-time'
26
27
 
27
28
  // ── Status badge — shown ONLY for states that need a glance (attention/error). ────
28
29
  // done/pending/streaming stay badge-free: a settled feed shouldn't be a wall of green chips —
@@ -35,31 +36,6 @@ const STATUS_BADGE: Partial<
35
36
  error: { label: 'Failed', statusToken: VX.status.bad },
36
37
  }
37
38
 
38
- // ── Dependency-free relative-time helper (no date-fns) ────────────────────────
39
-
40
- const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
41
-
42
- const RELATIVE_TIME_UNITS: readonly {
43
- readonly unit: Intl.RelativeTimeFormatUnit
44
- readonly ms: number
45
- }[] = [
46
- { unit: 'year', ms: 31_536_000_000 },
47
- { unit: 'month', ms: 2_628_000_000 },
48
- { unit: 'week', ms: 604_800_000 },
49
- { unit: 'day', ms: 86_400_000 },
50
- { unit: 'hour', ms: 3_600_000 },
51
- { unit: 'minute', ms: 60_000 },
52
- ]
53
-
54
- /** Formats an epoch-ms timestamp as a short relative string ("3 hours ago", "just now"). */
55
- function formatRelativeTime(timestamp: number): string {
56
- const diffMs = timestamp - Date.now()
57
- const absMs = Math.abs(diffMs)
58
- if (absMs < 60_000) return 'just now'
59
- const unit = RELATIVE_TIME_UNITS.find(({ ms }) => absMs >= ms) ?? RELATIVE_TIME_UNITS.at(-1)!
60
- return RELATIVE_TIME_FORMAT.format(Math.round(diffMs / unit.ms), unit.unit)
61
- }
62
-
63
39
  // ── Row bodies ─────────────────────────────────────────────────────────────────
64
40
 
65
41
  /** Preview skeleton shown while a thread has no resolved outcome yet (never raw prompt/live text). */
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ThreadWorkspace — hydration gating (D1).
3
+ *
4
+ * A store built with `createAdapterThreadsStore` starts `!hydrated` until its first `listThreads`
5
+ * succeeds. Before this fix, `ThreadWorkspace` read only `threads.length` to decide between the
6
+ * feed and the empty state, so a server-backed store with real threads (a database, a real API)
7
+ * would render "no threads yet" for the whole initial round trip, then swap to the populated feed
8
+ * once it landed — a defect invisible in the playground's demo only because that demo's in-memory
9
+ * adapter seeds an empty Map, so there is nothing to flash to.
10
+ *
11
+ * These tests drive `ThreadWorkspace` directly against a hand-built `ThreadsStore` (not a real
12
+ * `createAdapterThreadsStore` instance) so each case can assert one exact `{ hydrated, threads }`
13
+ * combination without racing a real async adapter.
14
+ */
15
+ import { MantineProvider } from '@mantine/core'
16
+ import { cleanup, render, screen } from '@testing-library/react'
17
+ import { afterEach, describe, expect, test } from 'bun:test'
18
+ import type { ReactElement } from 'react'
19
+ import type { AgentThread, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent'
20
+ import { createThreadsStore } from '../agent'
21
+ import { ThreadWorkspace } from './thread-workspace'
22
+
23
+ afterEach(cleanup)
24
+
25
+ // `useMediaQuery` reads `window.matchMedia` inside a mount effect RTL flushes synchronously —
26
+ // force the wide-viewport branch so every case renders the feed pane (same idiom as
27
+ // thread-feed.test.tsx's `withReducedMotion`).
28
+ function withWideViewport<T>(fn: () => T): T {
29
+ const original = window.matchMedia
30
+ window.matchMedia = (query: string): MediaQueryList =>
31
+ ({
32
+ matches: false,
33
+ media: query,
34
+ onchange: null,
35
+ addListener: () => {},
36
+ removeListener: () => {},
37
+ addEventListener: () => {},
38
+ removeEventListener: () => {},
39
+ dispatchEvent: () => false,
40
+ }) as MediaQueryList
41
+ try {
42
+ return fn()
43
+ } finally {
44
+ window.matchMedia = original
45
+ }
46
+ }
47
+
48
+ function noop(): void {}
49
+
50
+ /** A transport that never resolves — no case here calls `start()`, so `stream` is never invoked. */
51
+ const stubTransport: AgentTransport<never, string> = {
52
+ stream: (): AsyncGenerator<never> => {
53
+ throw new Error('stubTransport.stream must not be called by these tests')
54
+ },
55
+ }
56
+
57
+ const stubResolveOutcome: OutcomeResolver = async (thread) => ({
58
+ title: thread.id,
59
+ summary: '',
60
+ status: 'done',
61
+ })
62
+
63
+ function buildThread(id: string): AgentThread {
64
+ return {
65
+ id,
66
+ messages: [],
67
+ outcome: { title: id, summary: '', status: 'done' },
68
+ status: 'done',
69
+ read: true,
70
+ createdAt: 0,
71
+ updatedAt: 0,
72
+ }
73
+ }
74
+
75
+ /** A minimal fixed `ThreadsStore` — every mutator is a no-op since no case here calls one. */
76
+ function makeStore(overrides: Partial<ThreadsStore>): ThreadsStore {
77
+ return {
78
+ threads: [],
79
+ activeId: null,
80
+ hydrated: true,
81
+ error: undefined,
82
+ select: noop,
83
+ create: () => 'unused',
84
+ appendMessage: noop,
85
+ setOutcome: noop,
86
+ setStatus: noop,
87
+ setResumeToken: noop,
88
+ markRead: noop,
89
+ remove: noop,
90
+ clear: noop,
91
+ ...overrides,
92
+ }
93
+ }
94
+
95
+ function renderWorkspace(store: ThreadsStore): ReactElement {
96
+ return (
97
+ <MantineProvider>
98
+ <ThreadWorkspace
99
+ useThreads={() => store}
100
+ transport={stubTransport}
101
+ resolveOutcome={stubResolveOutcome}
102
+ />
103
+ </MantineProvider>
104
+ )
105
+ }
106
+
107
+ describe('ThreadWorkspace hydration gating', () => {
108
+ test('hydrating + empty: renders the hydrating hold, never the empty-state copy', () => {
109
+ withWideViewport(() => {
110
+ render(renderWorkspace(makeStore({ hydrated: false, threads: [] })))
111
+ })
112
+
113
+ expect(screen.getByTestId('thread-workspace-hydrating')).toBeDefined()
114
+ expect(screen.queryByText('No threads yet')).toBeNull()
115
+ })
116
+
117
+ test('hydrating + already-populated: renders the feed content, not the hydrating hold', () => {
118
+ withWideViewport(() => {
119
+ render(renderWorkspace(makeStore({ hydrated: false, threads: [buildThread('cached-1')] })))
120
+ })
121
+
122
+ expect(screen.getByText('cached-1')).toBeDefined()
123
+ expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
124
+ })
125
+
126
+ test('failed load: renders the error state, never the hydrating hold', () => {
127
+ withWideViewport(() => {
128
+ render(
129
+ renderWorkspace(
130
+ makeStore({ hydrated: false, error: new Error('listThreads failed'), threads: [] }),
131
+ ),
132
+ )
133
+ })
134
+
135
+ expect(screen.getByTestId('thread-workspace-error')).toBeDefined()
136
+ expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
137
+ })
138
+
139
+ test('hydrated + empty: renders the empty state (no threads really is the truth)', () => {
140
+ withWideViewport(() => {
141
+ render(renderWorkspace(makeStore({ hydrated: true, threads: [] })))
142
+ })
143
+
144
+ expect(screen.getByText('No threads yet')).toBeDefined()
145
+ expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
146
+ })
147
+
148
+ test('real synchronous createThreadsStore: never shows the hydrating hold, even on first render', () => {
149
+ // The real localStorage-backed store (hydrated pinned `true` — see thread.test.ts), not the
150
+ // hand-built stub above, so this proves the gate reads a genuine ThreadsStore correctly and
151
+ // does not regress the synchronous path with a permanent skeleton.
152
+ const useThreads = createThreadsStore({ key: 'thread-workspace-sync-empty', version: 1 })
153
+
154
+ withWideViewport(() => {
155
+ render(
156
+ <MantineProvider>
157
+ <ThreadWorkspace
158
+ useThreads={useThreads}
159
+ transport={stubTransport}
160
+ resolveOutcome={stubResolveOutcome}
161
+ />
162
+ </MantineProvider>,
163
+ )
164
+ })
165
+
166
+ expect(screen.getByText('No threads yet')).toBeDefined()
167
+ expect(screen.queryByTestId('thread-workspace-hydrating')).toBeNull()
168
+ })
169
+ })
@@ -32,11 +32,12 @@
32
32
  * )
33
33
  * }
34
34
  */
35
- import { Box, Divider, Flex, Stack, Text } from '@mantine/core'
35
+ import { Box, Divider, Flex, Skeleton, Stack, Text } from '@mantine/core'
36
36
  import { useMediaQuery } from '@mantine/hooks'
37
37
  import type { JSX, ReactNode } from 'react'
38
38
  import type { AgentPart, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent'
39
39
  import { useAgentThreadRuns } from '../agent'
40
+ import { VX } from '../tokens'
40
41
  import { Composer } from './composer'
41
42
  import { ThreadDetailPanel } from './thread-detail-panel'
42
43
  import { ThreadFeed } from './thread-feed'
@@ -56,11 +57,23 @@ export type ThreadWorkspaceProps = {
56
57
  readonly resolveOutcome: OutcomeResolver<AgentPart>
57
58
  /** Placeholder for the new-thread composer pinned under the feed. */
58
59
  readonly newThreadPlaceholder?: string
59
- /** Rendered in place of the feed while there are no threads yet. */
60
+ /**
61
+ * Rendered in place of the feed once the store is hydrated and there really are no threads.
62
+ * Shown ONLY when `store.hydrated` is true — while an async store's initial load is still in
63
+ * flight, `ThreadWorkspace` holds a neutral loading state instead (see `FeedHydratingState`),
64
+ * so a server-backed workspace never asserts "no threads yet" before it actually knows that.
65
+ */
60
66
  readonly emptyState?: ReactNode
67
+ /**
68
+ * Rendered in place of the feed when the store's initial load has failed — `!store.hydrated &&
69
+ * store.error !== undefined` (see `ThreadsStore.hydrated`'s doc for the discriminant). Shown
70
+ * instead of `FeedHydratingState`, so a rejected `listThreads` surfaces a legible failure rather
71
+ * than an endless skeleton.
72
+ */
73
+ readonly errorState?: ReactNode
61
74
  }
62
75
 
63
- /** Default hint shown in the feed pane before any thread exists (overridable via `emptyState`). */
76
+ /** Default hint shown in the feed pane once hydrated with no threads (overridable via `emptyState`). */
64
77
  function FeedEmptyState(): JSX.Element {
65
78
  return (
66
79
  <Stack align="center" justify="center" gap={4} h="100%" p="xl">
@@ -74,6 +87,50 @@ function FeedEmptyState(): JSX.Element {
74
87
  )
75
88
  }
76
89
 
90
+ /**
91
+ * Neutral hold shown in the feed pane while an async store's initial `listThreads` is still in
92
+ * flight, has no threads to show yet, AND has not failed (`!store.hydrated && threads.length ===
93
+ * 0 && store.error === undefined`). Never shown for the synchronous `createThreadsStore`, whose
94
+ * `hydrated` is always `true` — see `ThreadsStore.hydrated`'s doc. A store that already holds
95
+ * cached threads while revalidating (`!hydrated` with a non-empty `threads`) skips this entirely
96
+ * and renders the feed normally. A failed load renders `FeedErrorState` instead of this.
97
+ */
98
+ function FeedHydratingState(): JSX.Element {
99
+ return (
100
+ <Stack gap="sm" p="sm" data-testid="thread-workspace-hydrating">
101
+ <Skeleton height={54} radius="sm" />
102
+ <Skeleton height={54} radius="sm" />
103
+ <Skeleton height={54} radius="sm" />
104
+ </Stack>
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Default failure hold shown in the feed pane when an async store's initial `listThreads` has
110
+ * rejected (`!store.hydrated && store.error !== undefined`), overridable via `errorState`. Never
111
+ * shown for the synchronous `createThreadsStore`, whose `error` is always `undefined` — see
112
+ * `ThreadsStore.error`'s doc.
113
+ */
114
+ function FeedErrorState(): JSX.Element {
115
+ return (
116
+ <Stack
117
+ align="center"
118
+ justify="center"
119
+ gap={4}
120
+ h="100%"
121
+ p="xl"
122
+ data-testid="thread-workspace-error"
123
+ >
124
+ <Text fw={600} size="sm" style={{ color: VX.status.bad }}>
125
+ Couldn&apos;t load threads
126
+ </Text>
127
+ <Text size="xs" c="dimmed" ta="center">
128
+ Something went wrong loading your threads. Try refreshing the page.
129
+ </Text>
130
+ </Stack>
131
+ )
132
+ }
133
+
77
134
  /**
78
135
  * A two-pane master-detail thread workspace: a scrollable feed of threads with an anchored
79
136
  * new-thread composer, and a detail panel for the open thread's live transcript. Collapses to a
@@ -88,6 +145,7 @@ export function ThreadWorkspace({
88
145
  resolveOutcome,
89
146
  newThreadPlaceholder,
90
147
  emptyState,
148
+ errorState,
91
149
  }: ThreadWorkspaceProps): JSX.Element {
92
150
  const store = useThreads()
93
151
  const runs = useAgentThreadRuns({ transport, store, resolveOutcome })
@@ -130,8 +188,14 @@ export function ThreadWorkspace({
130
188
  const feed = (
131
189
  <Flex direction="column" h="100%" style={{ overflow: 'hidden' }}>
132
190
  <Box style={{ flex: 1, minHeight: 0 }}>
133
- {store.threads.length === 0 ? (
134
- (emptyState ?? <FeedEmptyState />)
191
+ {threads.length === 0 ? (
192
+ store.hydrated ? (
193
+ (emptyState ?? <FeedEmptyState />)
194
+ ) : store.error !== undefined ? (
195
+ (errorState ?? <FeedErrorState />)
196
+ ) : (
197
+ <FeedHydratingState />
198
+ )
135
199
  ) : (
136
200
  <ThreadFeed threads={threads} activeId={store.activeId} onSelect={handleSelect} />
137
201
  )}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Virtualization contract shared by the transcript/feed components that can opt into
3
+ * `@tanstack/react-virtual` for long lists.
4
+ *
5
+ * The `VirtualizeProps` union IS the guard: a virtualizer needs a measured scroll container, so
6
+ * turning `virtualize` on REQUIRES a `height` and turning it off (or omitting it) FORBIDS one. See
7
+ * `virtualize.type-guard.test.ts` beside this file for a compile-time proof that the union actually
8
+ * rejects the invalid combinations.
9
+ *
10
+ * The one piece of runtime here is {@link resolveVirtualize} — the single narrowing point every
11
+ * component that accepts `VirtualizeProps` goes through, so no call site has to re-derive (or
12
+ * assert its way past) the union's `virtualize`-implies-`height` link.
13
+ *
14
+ * The other half of the contract, not encoded in the types (it's a runtime/composition rule, not
15
+ * a shape): a virtualized transcript OWNS ITS OWN SCROLL NODE (the element `getScrollElement`
16
+ * measures) and must NOT be nested inside `BasaltStickToBottom` — unlike the non-virtual path,
17
+ * which `ThreadDetailPanel` nests inside `BasaltStickToBottom` today (its transcript body).
18
+ * Stacking a virtualizer's own scroll container inside `BasaltStickToBottom`'s would give the
19
+ * transcript two competing owners of "which element scrolls" and break both the virtualizer's
20
+ * `scrollToEnd` and stick-to-bottom's anchor tracking.
21
+ *
22
+ * A third composition rule, same category (runtime, not a shape): a virtualized transcript
23
+ * tolerates being hidden via `display: none` on an ANCESTOR (e.g. sitting inside a collapsible
24
+ * `ThreadFeedRow`) — collapsing does not corrupt its measurement cache, and re-expanding restores
25
+ * the same scroll position (same top-visible row) it had before the collapse. This is NOT free of
26
+ * virtual-core: hiding an ancestor makes every mounted row's ResizeObserver box report `0` (a
27
+ * `display: none` ancestor un-lays-out its descendants), and virtual-core 3.17.1's default
28
+ * `measureElement`/`resizeItem` write that `0` into `itemSizeCache` with no floor — unguarded, a
29
+ * few collapse/expand cycles permanently shrink `getTotalSize()` and silently scroll the visible
30
+ * window to a DIFFERENT part of the transcript (`scrollTop` stays put; the content under it moves).
31
+ * `thread-message.tsx` closes this with a `measureElement` override (`resolveGuardedMeasurement`)
32
+ * that treats a `0` reading as unreliable and keeps the last-known size instead of committing it —
33
+ * see that function's doc for the full mechanism, including why `useVirtualizer`'s `enabled` option
34
+ * is NOT the fix (it wipes the measurement cache outright, worse than the bug). A consumer combining a
35
+ * virtualized transcript with its own hide-via-CSS container gets this for free; a consumer who
36
+ * unmounts/remounts the transcript instead of hiding it is unaffected either way (a fresh
37
+ * `useVirtualizer` call has no stale cache to poison).
38
+ *
39
+ * A fourth composition rule: a virtualized transcript SCROLLS ITSELF on mount. `initialScroll`
40
+ * defaults to `'end'` — `anchorTo: 'end'` and `followOnAppend` (both wired in `thread-message.tsx`)
41
+ * only keep an ALREADY-at-the-end transcript pinned as content changes; neither performs the initial
42
+ * jump, so left alone a freshly mounted virtualized transcript renders at message #0, not the
43
+ * newest. The fix fires exactly once, on first mount, via `scrollToEnd()` — never again, so a user
44
+ * who has scrolled up is never yanked back down by a later append (that is precisely the failure
45
+ * `followOnAppend` exists to avoid, and re-introducing it here would be worse than the defect this
46
+ * closes). A consumer wiring its OWN scroll-restoration (e.g. `initialOffset` /
47
+ * `initialMeasurementsCache`, restoring a prior session's position) wants `initialScroll: 'start'` —
48
+ * otherwise this default jump fires first and the restoration has to fight it. `initialScroll` is
49
+ * ignored (no scroll, no throw) for an empty transcript, and is deferred rather than treated as done
50
+ * if the transcript's own scroll container measures 0px on mount (e.g. mounted behind a hidden
51
+ * ancestor) — it then lands on the first commit where the container is genuinely measurable, which
52
+ * is what makes "opens at the newest message" hold for a transcript whose lazy virtualizer settled
53
+ * while its `ThreadFeedRow` was collapsed. See `thread-message.tsx`'s `applyInitialScroll` for the
54
+ * guard, and `VirtualizedRowsInner` for why its effect is deliberately dependency-free.
55
+ *
56
+ * Firing `scrollToEnd()` once is not sufficient on its own to land there, because virtual-core
57
+ * 3.17.1's own `_willUpdate` writes to the SAME scroll container on the very next commit it runs
58
+ * on, from state that lags behind the DOM: `_scrollToOffset` only ever records the write's
59
+ * intent (`_intendedScrollOffset`) — `this.scrollOffset` itself updates ASYNCHRONOUSLY, from the
60
+ * native `scroll` event, not from the call that requested it. So a commit that runs `_willUpdate`
61
+ * before this effect had a chance to fire (element-attach) or that re-derives an anchor from
62
+ * `getScrollOffset()` while `this.scrollOffset` is still the pre-jump value (the anchor branch,
63
+ * gated on `pendingScrollAnchor`) writes that stale offset — 0, on first mount — straight back to
64
+ * `scrollTop`, clobbering this effect's jump within roughly one extra commit of it landing.
65
+ * `applyInitialScroll` survives this the same way the hidden-ancestor case above already
66
+ * (accidentally) does: by not treating one successful call as permanent. It fires, then — on a
67
+ * LATER call, i.e. a later commit, exactly the cadence this dependency-free effect already runs on
68
+ * — reads the container's REAL `scrollTop`/`scrollHeight`/`clientHeight` (never virtual-core's own
69
+ * `scrollOffset`, which is exactly the stale value that caused the clobber) to check whether the
70
+ * jump survived; if it didn't, it re-fires, bounded by `MAX_INITIAL_SCROLL_ATTEMPTS` so a DOM that
71
+ * pathologically never reports settled can never be fought forever. `resolveInitialScrollAction`
72
+ * itself is untouched by any of this — it is still the one-shot "should I even attempt this"
73
+ * decision; `applyInitialScroll` layers the across-commits verification on top.
74
+ *
75
+ * `initialOffset` (`number | (() => number)`, `virtual-core`'s own construction-time seed for
76
+ * `scrollOffset`) was investigated as a way to avoid the clobber altogether and rejected: it takes
77
+ * a concrete pixel number, not an `'end'` sentinel, and the only number available at the point
78
+ * it would need to resolve — before any row has ever been measured — is an ESTIMATE
79
+ * (`estimateSize` × count), not the real total. It would relocate the transcript's initial paint
80
+ * from message #0 to an approximately-right position, but the moment real measurements replace
81
+ * those estimates the total size (and therefore the true end offset) changes, which still needs a
82
+ * corrective `scrollToEnd()` once rows have actually measured — the exact write this whole
83
+ * mechanism exists to protect. It also can't reference the `virtualizer` instance it would be
84
+ * computing an offset for without a separate ref indirection, since it is itself one of the options
85
+ * passed to construct that instance. Not a fix for this defect, just a smaller wrong first paint.
86
+ */
87
+
88
+ /** Tuning knobs passed through to `@tanstack/react-virtual`'s `useVirtualizer`. */
89
+ export type VirtualizeOptions = {
90
+ /** Extra rows rendered beyond the visible viewport, each side. Library default: 1. */
91
+ readonly overscan?: number
92
+ /** Estimated row height in px, used before a row has been measured. */
93
+ readonly estimateSize?: number
94
+ /** Where the transcript sits on first mount. Default 'end' — a chat transcript opens at the
95
+ newest message. 'start' opens at the oldest. See this module's fourth composition rule
96
+ above for the mechanism and why a consumer restoring its own scroll position wants 'start'. */
97
+ readonly initialScroll?: 'end' | 'start'
98
+ }
99
+
100
+ /**
101
+ * `virtualize: false` (or omitted) → no `height` prop (content-sized, non-scrolling-owner layout).
102
+ * `virtualize: true | VirtualizeOptions` → `height` is REQUIRED — the virtualizer measures a fixed
103
+ * scroll container, so there is no valid virtualized layout without one.
104
+ */
105
+ export type VirtualizeProps =
106
+ | { readonly virtualize?: false; readonly height?: never }
107
+ | { readonly virtualize: true | VirtualizeOptions; readonly height: number | string }
108
+
109
+ /** The enabled branch of {@link VirtualizeProps}, with `height` no longer optional. */
110
+ export type ResolvedVirtualize = {
111
+ readonly options: VirtualizeOptions
112
+ readonly height: number | string
113
+ }
114
+
115
+ /**
116
+ * Narrows a `VirtualizeProps` pair once, centrally: `null` when virtualization is off, otherwise
117
+ * the options object (normalizing the bare `virtualize: true` shorthand to `{}`) alongside the
118
+ * `height` the union guarantees is present. Every component accepting `VirtualizeProps` resolves
119
+ * through here rather than destructuring the two fields apart — destructuring severs the link TS
120
+ * needs, which is what forces a non-null assertion on `height` at each call site.
121
+ */
122
+ export function resolveVirtualize(props: VirtualizeProps): ResolvedVirtualize | null {
123
+ const { virtualize } = props
124
+ if (virtualize === undefined || virtualize === false) return null
125
+ // The union already REQUIRES `height` on this branch and rejects it on the other — that guarantee
126
+ // is enforced where it matters, at every call site (see virtualize.type-guard.test.ts). What TS
127
+ // won't do is propagate the narrowing FROM `virtualize` TO `height`: `virtualize`'s enabled branch
128
+ // is `true | VirtualizeOptions`, and the object constituent disqualifies it as a discriminant
129
+ // property. So the pairing is re-stated once, here, instead of a `height!` at each consumer.
130
+ const height = props.height as number | string
131
+ return { options: virtualize === true ? {} : virtualize, height }
132
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Compile-time proof that `VirtualizeProps` actually rejects the invalid `virtualize`/`height`
3
+ * combinations (not just documentation).
4
+ *
5
+ * This mirrors the `apps/playground/src/*.type-guard.ts` convention (one `@ts-expect-error` per bad
6
+ * line, proven by CI's `tsc --noEmit`) but lives HERE, beside the type, rather than in the
7
+ * playground app. `VirtualizeProps` IS public now (re-exported type-only from the agent-chat barrel
8
+ * and the root), so a playground fixture would also work — this stays put because a source-relative
9
+ * import proves the union at its definition, before the build, and needs no rebuild to stay honest.
10
+ * A co-located `.type-guard.ts` file under `src/` would get picked up by tsup's
11
+ * `src/**\/*.{ts,tsx}` build glob and ship an unused fixture in the published package; naming it
12
+ * `*.test.ts` excludes it from that glob (tsup.config.ts excludes `src/**\/*.test.{ts,tsx}`) while
13
+ * this package's own `tsc --noEmit` (and `bun test`, which type-checks test files per the repo
14
+ * convention) still verifies it.
15
+ */
16
+ import { describe, expect, test } from 'bun:test'
17
+ import type { VirtualizeProps } from './virtualize'
18
+
19
+ function accept(props: VirtualizeProps): VirtualizeProps {
20
+ return props
21
+ }
22
+
23
+ // ── Valid combinations — must type-check with no error ────────────────────────
24
+
25
+ accept({})
26
+ accept({ virtualize: false })
27
+ accept({ virtualize: true, height: 400 })
28
+ accept({ virtualize: true, height: '100%' })
29
+ accept({ virtualize: { overscan: 3 }, height: 400 })
30
+ accept({ virtualize: { estimateSize: 72 }, height: '50vh' })
31
+
32
+ // ── Invalid combinations — each MUST be a tsc error, one directive per bad line ─
33
+
34
+ // @ts-expect-error `height` is forbidden when `virtualize` is false
35
+ accept({ virtualize: false, height: 400 })
36
+ // @ts-expect-error `height` is required when `virtualize: true`
37
+ accept({ virtualize: true })
38
+ // @ts-expect-error `height` is required when `virtualize` is a VirtualizeOptions object
39
+ accept({ virtualize: { overscan: 2 } })
40
+ // @ts-expect-error `height` is forbidden when `virtualize` is omitted (defaults to the `false` branch)
41
+ accept({ height: 400 })
42
+
43
+ describe('VirtualizeProps (type-guard)', () => {
44
+ test('is a compile-time-only fixture — see the @ts-expect-error directives above', () => {
45
+ // No runtime behavior to assert: the proof is that this file type-checks at all. A single
46
+ // trivial assertion keeps this a normal bun:test file rather than an empty-suite oddity.
47
+ expect(true).toBe(true)
48
+ })
49
+ })
package/src/data/index.ts CHANGED
@@ -7,7 +7,11 @@
7
7
  *
8
8
  * Optional peers:
9
9
  * - @tanstack/react-table >=8 <9 (BasaltDataTable)
10
- * - @tanstack/react-virtual >=3 <4 (BasaltVirtualList)
10
+ * - @tanstack/react-virtual >=3.13.26 <4 (BasaltVirtualList)
11
+ *
12
+ * BasaltVirtualList itself works on any 3.x; the declared floor is set by ./agent-chat's
13
+ * ThreadTranscript virtualize mode (it calls scrollToEnd/anchorTo, added in virtual-core 3.16.0,
14
+ * first pinned by react-virtual 3.13.26). npm has one peer range per package, so do not lower it.
11
15
  *
12
16
  * Install with:
13
17
  * bun add @tanstack/react-table @tanstack/react-virtual
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ./data — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual,
3
3
  * rendered with a Mantine Box scroll container.
4
- * Optional peer: @tanstack/react-virtual >=3 <4.
4
+ * Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is
5
+ * set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it.
5
6
  *
6
7
  * @example
7
8
  * import { BasaltVirtualList } from 'basalt-ui/data'
@@ -2,7 +2,8 @@
2
2
  * ./data/virtual — BasaltVirtualList: a windowed virtual list over @tanstack/react-virtual,
3
3
  * rendered with a Mantine Box scroll container.
4
4
  *
5
- * Optional peer: @tanstack/react-virtual >=3 <4.
5
+ * Optional peer: @tanstack/react-virtual >=3.13.26 <4. This list works on any 3.x — the floor is
6
+ * set by ./agent-chat's virtualize mode (see ./data/index.ts), so do not lower it.
6
7
  *
7
8
  * Use this fine subpath instead of the `./data` barrel when your app only needs the virtual list
8
9
  * — it does NOT value-import @tanstack/react-table, so the data-table peer is never required.
package/src/index.ts CHANGED
@@ -113,6 +113,7 @@ export type { GuardKind } from './guard/types'
113
113
  export {
114
114
  ThreadWorkspace,
115
115
  ThreadFeed,
116
+ ThreadFeedRow,
116
117
  ThreadOutcomeCard,
117
118
  ThreadDetailPanel,
118
119
  Composer,
@@ -123,6 +124,7 @@ export {
123
124
  export type {
124
125
  ThreadWorkspaceProps,
125
126
  ThreadFeedProps,
127
+ ThreadFeedRowProps,
126
128
  ThreadOutcomeCardProps,
127
129
  ThreadDetailPanelProps,
128
130
  ComposerProps,
@@ -130,6 +132,9 @@ export type {
130
132
  ComposerAttachment,
131
133
  ComposerHandle,
132
134
  ThreadTranscriptProps,
135
+ MessageAffordances,
136
+ VirtualizeOptions,
137
+ VirtualizeProps,
133
138
  ToolChipProps,
134
139
  } from './agent-chat'
135
140
  export {
@@ -419,4 +419,15 @@
419
419
  .root[data-collapsed] .childList {
420
420
  display: none;
421
421
  }
422
+
423
+ /* `navExtra` has no sensible icon-rail representation (arbitrary consumer content, not a nav
424
+ * row) — hide it in the rail, same treatment as `.childList`. It stays mounted (never a JS
425
+ * conditional on `collapsed`): `collapsed` is one value shared by the desktop rail AND the
426
+ * mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` only ever un-collapses
427
+ * the mobile side), and the rail-vs-drawer split lives ENTIRELY in this media query, never in JS —
428
+ * a JS gate on `collapsed` would also drop `navExtra` from the mobile drawer, which opens at full
429
+ * width regardless of the persisted rail state. */
430
+ .root[data-collapsed] .navExtra {
431
+ display: none;
432
+ }
422
433
  }
@@ -29,6 +29,7 @@ declare const classes: {
29
29
  readonly sectionHeader: string
30
30
  readonly subnavDropdown: string
31
31
  readonly childList: string
32
+ readonly navExtra: string
32
33
  readonly ghostIcon: string
33
34
  }
34
35
  export default classes