basalt-ui 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +17 -0
- package/README.md +38 -35
- package/agent/rules/basalt-agent.md +14 -0
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +5 -1
- package/dist/agent/adapter.d.ts.map +1 -1
- package/dist/agent/adapter.js +22 -13
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/id.d.ts +67 -19
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +21 -7
- package/dist/agent/thread.d.ts +4 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent-chat/index.d.ts +4 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +315 -34
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +29 -3
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +3 -2
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/llms.txt +6 -6
- package/package.json +2 -2
- package/src/agent/adapter.test.ts +237 -0
- package/src/agent/adapter.ts +72 -14
- package/src/agent/ai-sdk-transport.test.ts +64 -0
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/id.test.ts +95 -1
- package/src/agent/id.ts +98 -28
- package/src/agent/thread.ts +4 -0
- package/src/agent/use-agent-stream.test.tsx +22 -0
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.test.tsx +326 -0
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
- package/src/agent-chat/index.ts +12 -0
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +937 -3
- package/src/agent-chat/thread-message.tsx +766 -38
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +69 -5
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +5 -0
- package/src/surfaces.ts +2 -1
- package/src/theme/shadow-surfaces.test.ts +9 -0
|
@@ -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
|
+
})
|