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
@@ -0,0 +1,274 @@
1
+ /**
2
+ * ThreadFeedRow — the inline-expanding "Slack shape" projection of one `AgentThread`: a header row
3
+ * that expands IN PLACE to reveal the thread's live transcript and a composer, instead of opening a
4
+ * separate detail pane (compare `ThreadOutcomeCard` + `ThreadDetailPanel`, the inbox variant).
5
+ * `ThreadOutcomeCard` is untouched by this — it stays inbox-shaped by design and deliberately never
6
+ * renders live text (its module doc, and its `isPreviewing` branch, which shows a skeleton rather
7
+ * than the in-flight turn); this is a second, sibling component, not a mode on the first.
8
+ *
9
+ * LOAD-BEARING INVARIANT — read before touching the expand/collapse mechanics: once a row has been
10
+ * expanded for the first time, its transcript+composer subtree mounts LAZILY (nothing renders before
11
+ * that first expand) and then STAYS MOUNTED for the rest of the row's lifetime. Collapsing hides it
12
+ * with CSS only (`display: none`); it never unmounts.
13
+ *
14
+ * Do NOT delegate this to Mantine `Collapse`. On the installed `@mantine/core@9.3.0` a bare
15
+ * `<Collapse expanded>` happens to do the right thing (children stay mounted, hidden via
16
+ * `display: none` from `useCollapse`) — but Mantine master has already flipped the defaults to
17
+ * `keepMounted: true` + `keepMountedMode: 'activity'`, which wraps children in React 19 `<Activity>`.
18
+ * `<Activity mode="hidden">` DESTROYS the subtree's effects and RE-CREATES them on show, so the day
19
+ * this repo bumps Mantine, a bare `Collapse` would silently start re-firing every effect in the
20
+ * transcript on every re-open — for a streaming transport's subscription/resume effect, a literal
21
+ * duplicate stream replay. Own the show/hide here, in plain CSS, so no upstream default change can
22
+ * flip the semantics out from under this component. There are two separate guarantees and both are
23
+ * required: never render before the first expand (lazy), and never unmount after (kept).
24
+ *
25
+ * @example
26
+ * import { ThreadFeedRow } from 'basalt-ui'
27
+ *
28
+ * <ThreadFeedRow
29
+ * thread={thread}
30
+ * expanded={openId === thread.id}
31
+ * onToggle={(id) => setOpenId((current) => (current === id ? null : id))}
32
+ * onSend={(payload) => send(thread.id, payload)}
33
+ * />
34
+ */
35
+ import { Box, Group, Stack, Text, UnstyledButton } from '@mantine/core'
36
+ import type { JSX } from 'react'
37
+ import { useState } from 'react'
38
+ import type {
39
+ AgentThread,
40
+ ForeignPart,
41
+ PartRenderer,
42
+ PartRenderers,
43
+ StreamStatus,
44
+ TranscriptPart,
45
+ } from '../agent'
46
+ import { VX } from '../tokens'
47
+ import type { ComposerProps, ComposerSubmit } from './composer'
48
+ import { Composer } from './composer'
49
+ import type { MessageAffordances } from './message-affordances'
50
+ import { formatRelativeTime } from './relative-time'
51
+ import { ThreadTranscript } from './thread-message'
52
+ import { resolveVirtualize } from './virtualize'
53
+ import type { VirtualizeProps } from './virtualize'
54
+
55
+ /** A minimal, dependency-free chevron — rotates 90deg when expanded (same inline-svg idiom as
56
+ * `composer.tsx`'s `SendGlyph`/`StopGlyph`: no icon-library dependency for a one-off glyph). */
57
+ function ChevronGlyph({ expanded }: { expanded: boolean }): JSX.Element {
58
+ return (
59
+ <svg
60
+ width={14}
61
+ height={14}
62
+ viewBox="0 0 24 24"
63
+ fill="none"
64
+ aria-hidden
65
+ style={{
66
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
67
+ transition: 'transform 120ms ease',
68
+ flexShrink: 0,
69
+ }}
70
+ >
71
+ <path
72
+ d="M9 6l6 6l-6 6"
73
+ stroke="currentColor"
74
+ strokeWidth={2}
75
+ strokeLinecap="round"
76
+ strokeLinejoin="round"
77
+ />
78
+ </svg>
79
+ )
80
+ }
81
+
82
+ type ThreadFeedRowBase = {
83
+ /**
84
+ * The thread this row projects. Typed against `AgentThread<TranscriptPart>` — not the plain,
85
+ * `AgentPart`-defaulted `AgentThread` `ThreadFeed` uses, and not a type parameter. A consumer
86
+ * with a registered `ForeignPart` union passes its own threads straight through with no generic
87
+ * argument and no cast, because `TranscriptPart` is the widest part type and arrays are
88
+ * structurally covariant: `AgentPart[]` and `ConsumerPart[]` both widen into `TranscriptPart[]`.
89
+ * Widening the row is what lets both `ThreadFeed` (non-generic, `AgentThread<AgentPart>`) and a
90
+ * part-registry consumer feed the same component.
91
+ */
92
+ readonly thread: AgentThread<TranscriptPart>
93
+ /** Whether this row's transcript+composer body is currently visible. */
94
+ readonly expanded: boolean
95
+ /** Called with the thread's id when the header is clicked/activated. */
96
+ readonly onToggle: (id: string) => void
97
+ /** The live (in-flight) assistant turn's parts, when a run is streaming for this thread. */
98
+ readonly liveParts?: readonly TranscriptPart[]
99
+ /** The live run's status — drives the in-progress indicator on the live block. */
100
+ readonly liveStatus?: StreamStatus
101
+ /** Consumer renderers keyed by part.type, forwarded to `ThreadTranscript`. */
102
+ readonly renderers?: PartRenderers
103
+ /** Forwarded to `ThreadTranscript` — see its own prop doc. */
104
+ readonly fallbackRenderer?: PartRenderer<ForeignPart>
105
+ /**
106
+ * Per-message hover-row contract (timestamp/copy/regenerate/actions), forwarded verbatim to the
107
+ * row's `ThreadTranscript`. Shared with it via `message-affordances.ts` so the two cannot drift
108
+ * on what an unset field means.
109
+ */
110
+ readonly affordances?: MessageAffordances
111
+ /** Forwarded to `ThreadTranscript` — suppresses role label + chrome on same-speaker runs.
112
+ * @default true */
113
+ readonly groupConsecutive?: boolean
114
+ /** Called with the composer's submit payload. */
115
+ readonly onSend: (payload: ComposerSubmit) => void
116
+ /** Shown as the composer's Stop action while `liveStatus === 'streaming'`. */
117
+ readonly onStop?: () => void
118
+ /** Forwarded to the row's `Composer`, minus the three props this component always wires itself
119
+ * (`onSubmit`, `onStop`, and `streaming` — the last derived from `liveStatus` so a consumer can't
120
+ * pass one that disagrees with the row's own live status). */
121
+ readonly composerProps?: Omit<ComposerProps, 'onSubmit' | 'onStop' | 'streaming'>
122
+ }
123
+
124
+ /**
125
+ * `virtualize`/`height` are forwarded straight to the row's `ThreadTranscript`, carrying the same
126
+ * union guard: an inline row holding a very long thread can window it, and doing so REQUIRES a
127
+ * `height` (the transcript then owns a fixed-height scroll node inside the expanded body, instead
128
+ * of the body growing to the thread's full length). Omit both and the row's transcript is
129
+ * content-sized, as before.
130
+ */
131
+ export type ThreadFeedRowProps = ThreadFeedRowBase & VirtualizeProps
132
+
133
+ /** The row header's title: the resolved outcome title, else a plain placeholder — this row never
134
+ * falls back to scanning the first user message the way `ThreadOutcomeCard`'s `promptOf` does,
135
+ * since an inline row's own expanded transcript already shows that prompt a scroll away. */
136
+ function rowTitle(thread: AgentThread<TranscriptPart>): string {
137
+ return thread.outcome?.title ?? 'Untitled thread'
138
+ }
139
+
140
+ /**
141
+ * One inline-expanding thread row: a header (title + relative timestamp) that toggles a lazily
142
+ * mounted, kept-mounted transcript + composer body. See the module doc for the mount lifecycle
143
+ * invariant this component exists to guarantee.
144
+ *
145
+ * @example
146
+ * <ThreadFeedRow thread={thread} expanded={expanded} onToggle={onToggle} onSend={onSend} />
147
+ */
148
+ export function ThreadFeedRow(props: ThreadFeedRowProps): JSX.Element {
149
+ const {
150
+ thread,
151
+ expanded,
152
+ onToggle,
153
+ liveParts,
154
+ liveStatus,
155
+ renderers,
156
+ fallbackRenderer,
157
+ affordances,
158
+ groupConsecutive,
159
+ onSend,
160
+ onStop,
161
+ composerProps,
162
+ } = props
163
+
164
+ // Resolved through the shared narrowing point rather than by destructuring `virtualize`/`height`
165
+ // apart — see `resolveVirtualize`'s doc for why the two must be read together.
166
+ const virtualized = resolveVirtualize(props)
167
+ const virtualizeProps: VirtualizeProps =
168
+ virtualized === null ? {} : { virtualize: virtualized.options, height: virtualized.height }
169
+
170
+ // Lazy-mount + keep-mounted: flips true on the row's first expand and never resets. Everything
171
+ // below this line renders — once — the first time `expanded` becomes true, and stays in the tree
172
+ // (hidden via `display` only) for every collapse/expand after that. See the module doc.
173
+ //
174
+ // Set during render, not a `useEffect` — React's documented "adjusting state as props change"
175
+ // pattern (bails out via the `!hasOpened` guard, so this runs at most once per mount). A
176
+ // `useEffect` would flip `hasOpened` only AFTER the first expand's render had already committed
177
+ // with the body absent, so the header's first expand painted one tick before the body appeared.
178
+ // Setting it here makes React restart this render with `hasOpened` already true, so the header
179
+ // and the newly-lazy-mounted body commit in the same paint.
180
+ const [hasOpened, setHasOpened] = useState(expanded)
181
+ if (expanded && !hasOpened) {
182
+ setHasOpened(true)
183
+ }
184
+
185
+ const summary = thread.outcome?.summary ?? ''
186
+
187
+ // The composer half of `liveStatus`: `Composer` only shows its Stop action and gates the
188
+ // textarea when it is TOLD a run is streaming (`composer.tsx`'s `showStop`/`inputDisabled`) — it
189
+ // has no way to infer that from `onStop` alone. Forwarding `onStop` without this was the defect:
190
+ // Stop never rendered and a second turn could still be typed into a live thread.
191
+ const streaming = liveStatus === 'streaming'
192
+
193
+ return (
194
+ <Box
195
+ style={{
196
+ borderRadius: VX.radiusCard,
197
+ boxShadow: VX.shadowCard,
198
+ backgroundColor: VX.surface.panel,
199
+ overflow: 'hidden',
200
+ }}
201
+ >
202
+ <UnstyledButton
203
+ onClick={() => onToggle(thread.id)}
204
+ w="100%"
205
+ aria-expanded={expanded}
206
+ style={{
207
+ display: 'block',
208
+ padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)',
209
+ }}
210
+ >
211
+ <Group justify="space-between" gap="xs" wrap="nowrap" align="center">
212
+ <Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
213
+ <Text
214
+ fw={550}
215
+ lineClamp={1}
216
+ style={{
217
+ fontFamily: 'var(--basalt-font-head)',
218
+ fontSize: VX.text.md,
219
+ fontStretch: '88%',
220
+ }}
221
+ >
222
+ {rowTitle(thread)}
223
+ </Text>
224
+ {summary.length > 0 && (
225
+ <Text size="xs" c="dimmed" lineClamp={1}>
226
+ {summary}
227
+ </Text>
228
+ )}
229
+ </Stack>
230
+ <Text
231
+ style={{
232
+ fontFamily: 'var(--basalt-font-mono)',
233
+ fontSize: VX.text.micro,
234
+ color: VX.faint,
235
+ flexShrink: 0,
236
+ }}
237
+ >
238
+ {formatRelativeTime(thread.updatedAt)}
239
+ </Text>
240
+ <ChevronGlyph expanded={expanded} />
241
+ </Group>
242
+ </UnstyledButton>
243
+ {hasOpened && (
244
+ <Box
245
+ data-testid="thread-feed-row-body"
246
+ style={{
247
+ display: expanded ? 'block' : 'none',
248
+ padding: 'var(--mantine-spacing-sm)',
249
+ paddingTop: 0,
250
+ }}
251
+ >
252
+ <Stack gap="sm">
253
+ <ThreadTranscript
254
+ messages={thread.messages}
255
+ {...(liveParts !== undefined ? { liveParts } : {})}
256
+ {...(liveStatus !== undefined ? { liveStatus } : {})}
257
+ {...(renderers !== undefined ? { renderers } : {})}
258
+ {...(fallbackRenderer !== undefined ? { fallbackRenderer } : {})}
259
+ {...(affordances !== undefined ? { affordances } : {})}
260
+ {...(groupConsecutive !== undefined ? { groupConsecutive } : {})}
261
+ {...virtualizeProps}
262
+ />
263
+ <Composer
264
+ onSubmit={onSend}
265
+ streaming={streaming}
266
+ {...(onStop !== undefined ? { onStop } : {})}
267
+ {...composerProps}
268
+ />
269
+ </Stack>
270
+ </Box>
271
+ )}
272
+ </Box>
273
+ )
274
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * ThreadFeed — `variant`/`renderRow` (AGENT-CHAT-SPEC.md §12, additive to the existing
3
+ * `ThreadOutcomeCard` inbox behaviour). Covers both the reduced-motion and the animated
4
+ * (`AnimatePresence`) render branches, since `variant`/`renderRow` have to work identically in
5
+ * both — see `thread-feed.tsx`'s module doc for why that split exists in the first place.
6
+ */
7
+ import { MantineProvider } from '@mantine/core'
8
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
9
+ import { afterEach, describe, expect, test } from 'bun:test'
10
+ import type { ReactElement } from 'react'
11
+ import { useState } from 'react'
12
+ import type { AgentThread } from '../agent'
13
+ import type { ComposerSubmit } from './composer'
14
+ import { ThreadFeed } from './thread-feed'
15
+
16
+ afterEach(cleanup)
17
+
18
+ function buildThreads(): AgentThread[] {
19
+ return [
20
+ {
21
+ id: 't1',
22
+ messages: [],
23
+ outcome: { title: 'First thread', summary: 'Summary one', status: 'done' },
24
+ status: 'done',
25
+ read: true,
26
+ createdAt: 0,
27
+ updatedAt: 0,
28
+ },
29
+ ]
30
+ }
31
+
32
+ /** Temporarily forces `useReducedMotion()`'s result for the duration of `fn` — `@mantine/hooks`'
33
+ * `useMediaQuery` reads `window.matchMedia(query).matches` inside its mount effect, which RTL's
34
+ * `render` flushes synchronously (wrapped in `act`) before returning. See `use-media-query.mjs`. */
35
+ function withReducedMotion<T>(matches: boolean, fn: () => T): T {
36
+ const original = window.matchMedia
37
+ window.matchMedia = (query: string): MediaQueryList =>
38
+ ({
39
+ matches,
40
+ media: query,
41
+ onchange: null,
42
+ addListener: () => {},
43
+ removeListener: () => {},
44
+ addEventListener: () => {},
45
+ removeEventListener: () => {},
46
+ dispatchEvent: () => false,
47
+ }) as MediaQueryList
48
+ try {
49
+ return fn()
50
+ } finally {
51
+ window.matchMedia = original
52
+ }
53
+ }
54
+
55
+ function renderFeed(ui: ReactElement) {
56
+ return render(<MantineProvider>{ui}</MantineProvider>)
57
+ }
58
+
59
+ describe.each([
60
+ ['animated', false],
61
+ ['reduced motion', true],
62
+ ])('ThreadFeed row selection (%s)', (_label, reducedMotion) => {
63
+ test('variant "outcome" (default) renders ThreadOutcomeCard unchanged', () => {
64
+ withReducedMotion(reducedMotion, () => {
65
+ renderFeed(<ThreadFeed threads={buildThreads()} activeId={null} onSelect={() => {}} />)
66
+ })
67
+
68
+ expect(screen.getByText('First thread')).toBeDefined()
69
+ expect(screen.getByText('Summary one')).toBeDefined()
70
+ })
71
+
72
+ test('variant "inline" renders ThreadFeedRow instead of ThreadOutcomeCard', () => {
73
+ withReducedMotion(reducedMotion, () => {
74
+ renderFeed(
75
+ <ThreadFeed
76
+ threads={buildThreads()}
77
+ activeId={null}
78
+ onSelect={() => {}}
79
+ variant="inline"
80
+ />,
81
+ )
82
+ })
83
+
84
+ // ThreadFeedRow renders the same title text, but as a toggle button (role=button) rather than
85
+ // ThreadOutcomeCard's selectable row — the observable difference between the two variants.
86
+ expect(screen.getByText('First thread')).toBeDefined()
87
+ expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
88
+ })
89
+
90
+ test('renderRow overrides BOTH variants', () => {
91
+ withReducedMotion(reducedMotion, () => {
92
+ renderFeed(
93
+ <ThreadFeed
94
+ threads={buildThreads()}
95
+ activeId={null}
96
+ onSelect={() => {}}
97
+ renderRow={(thread) => <div data-testid={`custom-${thread.id}`}>{thread.id}</div>}
98
+ />,
99
+ )
100
+ })
101
+
102
+ expect(screen.getByTestId('custom-t1')).toBeDefined()
103
+ // The default outcome-card content must NOT have rendered instead.
104
+ expect(screen.queryByText('First thread')).toBeNull()
105
+ })
106
+
107
+ test('renderRow overrides the "inline" variant too', () => {
108
+ withReducedMotion(reducedMotion, () => {
109
+ renderFeed(
110
+ <ThreadFeed
111
+ threads={buildThreads()}
112
+ activeId={null}
113
+ onSelect={() => {}}
114
+ variant="inline"
115
+ renderRow={(thread) => <div data-testid={`custom-${thread.id}`}>{thread.id}</div>}
116
+ />,
117
+ )
118
+ })
119
+
120
+ expect(screen.getByTestId('custom-t1')).toBeDefined()
121
+ expect(screen.queryByRole('button')).toBeNull()
122
+ })
123
+ })
124
+
125
+ // ── The inline row's own collapse tracking ───────────────────────────────────────────────────
126
+ // Wiring `onToggle={onSelect}` directly (the previous shape) meant a plain, natural
127
+ // `onSelect={setActiveId}` consumer could never collapse a row: re-selecting the same id twice is
128
+ // a no-op state update, so `aria-expanded` got stuck at `true` forever. These use exactly that
129
+ // natural, non-toggling setter — the trap case — to prove the fix without leaning on a consumer
130
+ // that already special-cases re-selection.
131
+
132
+ /** A minimal natural consumer: a plain `useState` setter passed straight through as `onSelect`,
133
+ * the exact shape the previous wiring broke. */
134
+ function NaiveSingleSelectFeed({ threads }: { threads: AgentThread[] }): ReactElement {
135
+ const [activeId, setActiveId] = useState<string | null>(null)
136
+ return (
137
+ <ThreadFeed threads={threads} activeId={activeId} onSelect={setActiveId} variant="inline" />
138
+ )
139
+ }
140
+
141
+ describe('ThreadFeed inline variant — collapse decoupled from onSelect', () => {
142
+ test('clicking an unselected row expands it and reports the selection', () => {
143
+ render(
144
+ <MantineProvider>
145
+ <NaiveSingleSelectFeed threads={buildThreads()} />
146
+ </MantineProvider>,
147
+ )
148
+
149
+ const header = screen.getByRole('button')
150
+ expect(header.getAttribute('aria-expanded')).toBe('false')
151
+
152
+ fireEvent.click(header)
153
+
154
+ expect(header.getAttribute('aria-expanded')).toBe('true')
155
+ })
156
+
157
+ test('clicking the already-selected row collapses it — the defect this fix targets', () => {
158
+ render(
159
+ <MantineProvider>
160
+ <NaiveSingleSelectFeed threads={buildThreads()} />
161
+ </MantineProvider>,
162
+ )
163
+
164
+ const header = screen.getByRole('button')
165
+ fireEvent.click(header)
166
+ expect(header.getAttribute('aria-expanded')).toBe('true')
167
+
168
+ // The second click, on the SAME row, with a naive `setActiveId` that turns this into a no-op
169
+ // state update — this is exactly the click the previous `onToggle={onSelect}` wiring couldn't
170
+ // collapse from.
171
+ fireEvent.click(header)
172
+
173
+ expect(header.getAttribute('aria-expanded')).toBe('false')
174
+ })
175
+
176
+ test('re-clicking after a collapse re-expands the same row', () => {
177
+ render(
178
+ <MantineProvider>
179
+ <NaiveSingleSelectFeed threads={buildThreads()} />
180
+ </MantineProvider>,
181
+ )
182
+
183
+ const header = screen.getByRole('button')
184
+ fireEvent.click(header) // expand
185
+ fireEvent.click(header) // collapse
186
+ fireEvent.click(header) // expand again
187
+
188
+ expect(header.getAttribute('aria-expanded')).toBe('true')
189
+ })
190
+ })
191
+
192
+ // ── The built-in inline row's composer: real send channel vs. visibly inert ─────────────────
193
+ // `variant='inline'` used to wire the row's composer to a hardcoded no-op — a live, enabled
194
+ // textarea + Send button that silently discarded whatever was typed into it. These assert both
195
+ // halves of the fix: `onSend` makes it actually work, and omitting `onSend` renders the composer
196
+ // disabled instead of a live dead end.
197
+
198
+ describe('ThreadFeed inline variant — composer send channel', () => {
199
+ test('onSend is called with the thread and the composer payload on submit', () => {
200
+ const sent: { thread: AgentThread | null; payload: ComposerSubmit | null } = {
201
+ thread: null,
202
+ payload: null,
203
+ }
204
+ const threads = buildThreads()
205
+
206
+ render(
207
+ <MantineProvider>
208
+ <ThreadFeed
209
+ threads={threads}
210
+ activeId="t1"
211
+ onSelect={() => {}}
212
+ variant="inline"
213
+ onSend={(thread, payload) => {
214
+ sent.thread = thread
215
+ sent.payload = payload
216
+ }}
217
+ />
218
+ </MantineProvider>,
219
+ )
220
+
221
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
222
+ expect(textarea.disabled).toBe(false)
223
+
224
+ fireEvent.change(textarea, { target: { value: 'hello there' } })
225
+ fireEvent.click(screen.getByLabelText('Send message'))
226
+
227
+ expect(sent.thread?.id).toBe('t1')
228
+ expect(sent.payload?.text).toBe('hello there')
229
+ })
230
+
231
+ test('without onSend, the composer renders disabled rather than a live no-op', () => {
232
+ render(
233
+ <MantineProvider>
234
+ <ThreadFeed threads={buildThreads()} activeId="t1" onSelect={() => {}} variant="inline" />
235
+ </MantineProvider>,
236
+ )
237
+
238
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
239
+ expect(textarea.disabled).toBe(true)
240
+ })
241
+ })