@a4ui/core 0.32.0 → 0.34.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 (63) hide show
  1. package/dist/charts/BarList.d.ts +33 -0
  2. package/dist/charts/CategoryBar.d.ts +23 -0
  3. package/dist/charts/StatusTracker.d.ts +27 -0
  4. package/dist/charts/index.d.ts +3 -0
  5. package/dist/charts.js +446 -316
  6. package/dist/elements.css +278 -0
  7. package/dist/full.css +278 -0
  8. package/dist/index.d.ts +25 -1
  9. package/dist/index.js +6423 -4195
  10. package/dist/ui/AudioWaveform.d.ts +22 -0
  11. package/dist/ui/Callout.d.ts +27 -0
  12. package/dist/ui/Confetti.d.ts +46 -0
  13. package/dist/ui/CouponField.d.ts +33 -0
  14. package/dist/ui/CursorTrail.d.ts +25 -0
  15. package/dist/ui/DiffViewer.d.ts +33 -0
  16. package/dist/ui/FollowingPointer.d.ts +24 -0
  17. package/dist/ui/GanttChart.d.ts +37 -0
  18. package/dist/ui/Globe.d.ts +44 -0
  19. package/dist/ui/Kanban.d.ts +49 -0
  20. package/dist/ui/Lamp.d.ts +22 -0
  21. package/dist/ui/Lightbox.d.ts +41 -0
  22. package/dist/ui/ModelPicker.d.ts +38 -0
  23. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  24. package/dist/ui/PivotTable.d.ts +37 -0
  25. package/dist/ui/ReasoningTrace.d.ts +28 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/Snippet.d.ts +19 -0
  28. package/dist/ui/SuggestionChips.d.ts +25 -0
  29. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  30. package/dist/ui/TreeTable.d.ts +52 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  33. package/dist/ui/WorldMap.d.ts +36 -0
  34. package/package.json +1 -1
  35. package/src/charts/BarList.tsx +83 -0
  36. package/src/charts/CategoryBar.tsx +95 -0
  37. package/src/charts/StatusTracker.tsx +81 -0
  38. package/src/charts/index.ts +3 -0
  39. package/src/index.ts +48 -1
  40. package/src/ui/AudioWaveform.tsx +190 -0
  41. package/src/ui/Callout.tsx +66 -0
  42. package/src/ui/Confetti.tsx +131 -0
  43. package/src/ui/CouponField.tsx +120 -0
  44. package/src/ui/CursorTrail.tsx +88 -0
  45. package/src/ui/DiffViewer.tsx +166 -0
  46. package/src/ui/FollowingPointer.tsx +112 -0
  47. package/src/ui/GanttChart.tsx +283 -0
  48. package/src/ui/Globe.tsx +317 -0
  49. package/src/ui/Kanban.tsx +250 -0
  50. package/src/ui/Lamp.tsx +88 -0
  51. package/src/ui/Lightbox.tsx +261 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/OnboardingChecklist.tsx +163 -0
  54. package/src/ui/PivotTable.tsx +0 -0
  55. package/src/ui/ReasoningTrace.tsx +83 -0
  56. package/src/ui/SheetSnap.tsx +264 -0
  57. package/src/ui/Snippet.tsx +64 -0
  58. package/src/ui/SuggestionChips.tsx +65 -0
  59. package/src/ui/ToolCallTimeline.tsx +137 -0
  60. package/src/ui/TreeTable.tsx +172 -0
  61. package/src/ui/UsageMeter.tsx +71 -0
  62. package/src/ui/VideoPlayerShell.tsx +282 -0
  63. package/src/ui/WorldMap.tsx +191 -0
@@ -0,0 +1,264 @@
1
+ // Bottom sheet with drag-to-resize snap points (mobile filter/detail panels,
2
+ // map bottom sheets). Kobalte's Dialog (used by Drawer/Modal) has no notion of
3
+ // intermediate heights, so this rolls its own portal + backdrop + focus/Escape/
4
+ // body-scroll-lock (mirroring Drawer.tsx's overlay idiom) and its own raw
5
+ // pointerdown/move/up + pointer-capture drag loop (there's no shared drag
6
+ // primitive in the repo to reuse). The sheet's height is fixed to the tallest
7
+ // snap point; the *visible* portion is controlled by translating it down in
8
+ // pixels, snapped to one of `snapPoints` (fractions of `window.innerHeight`).
9
+ import { createEffect, createSignal, onCleanup, onMount, Show, type JSX } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { animate, motionReduced } from '../lib/motion'
13
+ import { Portal } from './Portal'
14
+
15
+ export interface SheetSnapProps {
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ /** Fractions of viewport height, ascending, e.g. `[0.4, 0.9]`. @default [0.5, 0.9] */
19
+ snapPoints?: number[]
20
+ /** Index into `snapPoints` to open at. @default 0 */
21
+ defaultSnap?: number
22
+ children: JSX.Element
23
+ class?: string
24
+ }
25
+
26
+ const DEFAULT_SNAPS = [0.5, 0.9]
27
+ const SPRING_STIFFNESS = 380
28
+ const SPRING_DAMPING = 38
29
+ /** Drag past the lowest snap point by this many px (with no help from velocity) dismisses. */
30
+ const DISMISS_DISTANCE_PX = 100
31
+ /** px/ms; a flick faster than this biases the resolved snap by one level, or dismisses near the bottom. */
32
+ const FLICK_VELOCITY = 0.5
33
+
34
+ const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max)
35
+
36
+ /**
37
+ * A draggable bottom sheet that rests at one of several height "snap points"
38
+ * (fractions of viewport height) instead of just open/closed. Drag the handle
39
+ * to resize with the finger; releasing snaps to the nearest point, biased by
40
+ * flick velocity (a fast swipe jumps a level), and a hard downward flick/drag
41
+ * past the lowest point dismisses. Falls back to an instant show/hide at
42
+ * `defaultSnap` — no drag physics — under reduced motion.
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * const [open, setOpen] = createSignal(false)
47
+ * <SheetSnap open={open()} onOpenChange={setOpen} snapPoints={[0.4, 0.9]}>
48
+ * <p>Sheet content…</p>
49
+ * </SheetSnap>
50
+ * ```
51
+ */
52
+ export function SheetSnap(props: SheetSnapProps): JSX.Element {
53
+ const snaps = () => (props.snapPoints?.length ? props.snapPoints : DEFAULT_SNAPS)
54
+ const defaultIndex = () => clamp(props.defaultSnap ?? 0, 0, snaps().length - 1)
55
+
56
+ const [mounted, setMounted] = createSignal(false)
57
+ const [translateY, setTranslateY] = createSignal(0)
58
+
59
+ let previouslyFocused: HTMLElement | null = null
60
+ let previousBodyOverflow = ''
61
+
62
+ // Plain (non-reactive) geometry — recomputed on open and on resize, read
63
+ // fresh inside the `translateY()`-dependent style expression below.
64
+ let maxHeightPx = 0
65
+ let translateYs: number[] = []
66
+ let snapIndex = 0
67
+
68
+ let controls: ReturnType<typeof animate> | undefined
69
+ let dragging = false
70
+ let dragStartClientY = 0
71
+ let dragStartTranslate = 0
72
+ let lastClientY = 0
73
+ let lastTime = 0
74
+ let velocity = 0 // px/ms, positive = moving down (toward closed)
75
+
76
+ const computeGeometry = () => {
77
+ const vh = window.innerHeight
78
+ const pixelHeights = snaps().map((f) => f * vh)
79
+ maxHeightPx = Math.max(...pixelHeights)
80
+ translateYs = pixelHeights.map((h) => maxHeightPx - h)
81
+ }
82
+
83
+ const lockScroll = () => {
84
+ previousBodyOverflow = document.body.style.overflow
85
+ document.body.style.overflow = 'hidden'
86
+ }
87
+ const unlockScroll = () => {
88
+ document.body.style.overflow = previousBodyOverflow
89
+ }
90
+
91
+ const animateTo = (target: number, onDone?: () => void) => {
92
+ controls?.stop()
93
+ if (motionReduced()) {
94
+ setTranslateY(target)
95
+ onDone?.()
96
+ return
97
+ }
98
+ controls = animate(translateY(), target, {
99
+ type: 'spring',
100
+ stiffness: SPRING_STIFFNESS,
101
+ damping: SPRING_DAMPING,
102
+ onUpdate: (v: number) => setTranslateY(v),
103
+ })
104
+ controls.finished.then(() => onDone?.()).catch(() => {})
105
+ }
106
+
107
+ // Mount/unmount + enter/exit animation, driven off the controlled `open` prop.
108
+ createEffect(() => {
109
+ if (props.open) {
110
+ if (mounted()) return
111
+ computeGeometry()
112
+ snapIndex = defaultIndex()
113
+ previouslyFocused = document.activeElement as HTMLElement | null
114
+ setTranslateY(motionReduced() ? translateYs[snapIndex] : maxHeightPx)
115
+ setMounted(true)
116
+ lockScroll()
117
+ } else if (mounted()) {
118
+ animateTo(maxHeightPx, () => {
119
+ setMounted(false)
120
+ unlockScroll()
121
+ previouslyFocused?.focus()
122
+ })
123
+ }
124
+ })
125
+
126
+ const resolveDragEnd = () => {
127
+ const current = translateY()
128
+ const mostClosedY = translateYs[0]
129
+
130
+ const draggedPastLowest = current - mostClosedY
131
+ const fastDownwardNearBottom =
132
+ velocity > FLICK_VELOCITY && current >= mostClosedY - DISMISS_DISTANCE_PX / 2
133
+ if (draggedPastLowest > DISMISS_DISTANCE_PX || fastDownwardNearBottom) {
134
+ props.onOpenChange(false)
135
+ return
136
+ }
137
+
138
+ let nearest = 0
139
+ let bestDist = Infinity
140
+ translateYs.forEach((t, i) => {
141
+ const d = Math.abs(current - t)
142
+ if (d < bestDist) {
143
+ bestDist = d
144
+ nearest = i
145
+ }
146
+ })
147
+
148
+ let target = nearest
149
+ if (velocity > FLICK_VELOCITY) target = Math.max(nearest - 1, 0)
150
+ else if (velocity < -FLICK_VELOCITY) target = Math.min(nearest + 1, translateYs.length - 1)
151
+
152
+ snapIndex = target
153
+ animateTo(translateYs[target])
154
+ }
155
+
156
+ const onHandlePointerDown = (e: PointerEvent) => {
157
+ if (motionReduced()) return
158
+ e.preventDefault()
159
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
160
+ controls?.stop()
161
+ dragging = true
162
+ dragStartClientY = e.clientY
163
+ dragStartTranslate = translateY()
164
+ lastClientY = e.clientY
165
+ lastTime = performance.now()
166
+ velocity = 0
167
+ }
168
+
169
+ const onHandlePointerMove = (e: PointerEvent) => {
170
+ if (!dragging) return
171
+ const next = Math.max(dragStartTranslate + (e.clientY - dragStartClientY), 0)
172
+ setTranslateY(next)
173
+
174
+ const now = performance.now()
175
+ const dt = now - lastTime
176
+ if (dt > 0) velocity = (e.clientY - lastClientY) / dt
177
+ lastClientY = e.clientY
178
+ lastTime = now
179
+ }
180
+
181
+ const onHandlePointerUp = (e: PointerEvent) => {
182
+ if (!dragging) return
183
+ dragging = false
184
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
185
+ resolveDragEnd()
186
+ }
187
+
188
+ const onKeyDown = (e: KeyboardEvent) => {
189
+ if (e.key === 'Escape' && props.open) props.onOpenChange(false)
190
+ }
191
+ const onResize = () => {
192
+ if (!mounted()) return
193
+ computeGeometry()
194
+ setTranslateY(translateYs[clamp(snapIndex, 0, translateYs.length - 1)])
195
+ }
196
+
197
+ onMount(() => {
198
+ document.addEventListener('keydown', onKeyDown)
199
+ window.addEventListener('resize', onResize)
200
+ })
201
+ onCleanup(() => {
202
+ document.removeEventListener('keydown', onKeyDown)
203
+ window.removeEventListener('resize', onResize)
204
+ controls?.stop()
205
+ if (mounted()) unlockScroll()
206
+ })
207
+
208
+ return (
209
+ <Show when={mounted()}>
210
+ <Portal>
211
+ <div
212
+ aria-hidden="true"
213
+ onClick={() => props.onOpenChange(false)}
214
+ class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-[2px]"
215
+ />
216
+ <div
217
+ ref={(el) => {
218
+ requestAnimationFrame(() => {
219
+ el.focus()
220
+ animateTo(translateYs[snapIndex])
221
+ })
222
+ }}
223
+ role="dialog"
224
+ aria-modal="true"
225
+ aria-label="Bottom sheet"
226
+ tabindex={-1}
227
+ class={cn(
228
+ 'fixed inset-x-0 bottom-0 z-[9999] flex flex-col overflow-hidden rounded-t-2xl border border-border bg-glass shadow-2xl outline-none',
229
+ props.class,
230
+ )}
231
+ style={{
232
+ height: `${maxHeightPx}px`,
233
+ transform: `translateY(${translateY()}px)`,
234
+ }}
235
+ >
236
+ <div
237
+ role="button"
238
+ tabindex={0}
239
+ aria-label="Drag handle"
240
+ onPointerDown={onHandlePointerDown}
241
+ onPointerMove={onHandlePointerMove}
242
+ onPointerUp={onHandlePointerUp}
243
+ onPointerCancel={onHandlePointerUp}
244
+ onKeyDown={(e) => {
245
+ if (e.key === 'ArrowUp') {
246
+ e.preventDefault()
247
+ snapIndex = Math.min(snapIndex + 1, translateYs.length - 1)
248
+ animateTo(translateYs[snapIndex])
249
+ } else if (e.key === 'ArrowDown') {
250
+ e.preventDefault()
251
+ snapIndex = Math.max(snapIndex - 1, 0)
252
+ animateTo(translateYs[snapIndex])
253
+ }
254
+ }}
255
+ class="flex shrink-0 touch-none items-center justify-center py-3 outline-none"
256
+ >
257
+ <div class="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
258
+ </div>
259
+ <div class="flex-1 overflow-y-auto overscroll-contain px-4 pb-4">{props.children}</div>
260
+ </div>
261
+ </Portal>
262
+ </Show>
263
+ )
264
+ }
@@ -0,0 +1,64 @@
1
+ // Single inline code block with a copy button, for a one-liner or short
2
+ // excerpt embedded in prose. Lighter than CodeTabs (which is a card with tabs
3
+ // for several samples) — Snippet is just one block, no chrome besides the
4
+ // copy affordance and an optional language tag.
5
+ import { Check, Copy } from 'lucide-solid'
6
+ import type { JSX } from 'solid-js'
7
+ import { createSignal, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ const COPIED_TIMEOUT_MS = 1500
12
+
13
+ export interface SnippetProps {
14
+ /** Source text to display and copy. */
15
+ code: string
16
+ /** Optional language tag shown as a small badge, e.g. `'tsx'`. Informational only, no highlighting. */
17
+ language?: string
18
+ class?: string
19
+ }
20
+
21
+ /**
22
+ * Single inline code block (mono, bordered, scrollable) with a copy button
23
+ * that morphs into a checkmark for 1.5s after copying. For one code sample —
24
+ * use {@link CodeTabs} instead when you need several tabs/languages.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <Snippet language="bash" code="pnpm add @a4ui/core" />
29
+ * ```
30
+ */
31
+ export function Snippet(props: SnippetProps): JSX.Element {
32
+ const [copied, setCopied] = createSignal(false)
33
+
34
+ const copy = () => {
35
+ if (!navigator.clipboard) return
36
+ navigator.clipboard.writeText(props.code).then(() => {
37
+ setCopied(true)
38
+ setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS)
39
+ })
40
+ }
41
+
42
+ return (
43
+ <div class={cn('relative rounded-lg border border-border bg-muted px-3 py-2', props.class)}>
44
+ <Show when={props.language}>
45
+ <span class="absolute right-9 top-2 rounded bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
46
+ {props.language}
47
+ </span>
48
+ </Show>
49
+ <button
50
+ type="button"
51
+ aria-label={copied() ? 'Copied' : 'Copy to clipboard'}
52
+ class="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
53
+ onClick={copy}
54
+ >
55
+ <Show when={copied()} fallback={<Copy class="h-3.5 w-3.5" />}>
56
+ <Check class="h-3.5 w-3.5" />
57
+ </Show>
58
+ </button>
59
+ <pre class="overflow-x-auto whitespace-pre pr-8 font-mono text-sm text-foreground">
60
+ <code>{props.code}</code>
61
+ </pre>
62
+ </div>
63
+ )
64
+ }
@@ -0,0 +1,65 @@
1
+ // Wrapping row of dismissible/selectable pill chips (e.g. AI follow-up
2
+ // suggestions). Plain buttons in a flex-wrap row — no Kobalte primitive
3
+ // covers this shape, so it's hand-rolled like AnnouncementBar's tag chips.
4
+ import { X } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface SuggestionChipsProps {
11
+ suggestions: string[]
12
+ onSelect?: (s: string) => void
13
+ onDismiss?: (s: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Wrapping row of pill chips, e.g. AI-suggested follow-up prompts. Clicking a
19
+ * chip calls `onSelect`; if `onDismiss` is passed, each chip also shows a
20
+ * small "x" that removes it without triggering `onSelect`. The select and
21
+ * dismiss controls are real sibling `<button>`s (not nested — a `<button>`
22
+ * can't validly contain another interactive element), so both are reachable
23
+ * and operable independently via Tab/Enter/Space with no extra ARIA wiring.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <SuggestionChips
28
+ * suggestions={['Summarize this', 'Explain like I\'m 5', 'Write tests']}
29
+ * onSelect={(s) => sendMessage(s)}
30
+ * onDismiss={(s) => setSuggestions((prev) => prev.filter((x) => x !== s))}
31
+ * />
32
+ * ```
33
+ */
34
+ export function SuggestionChips(props: SuggestionChipsProps): JSX.Element {
35
+ return (
36
+ <div class={cn('flex flex-wrap gap-2', props.class)}>
37
+ <For each={props.suggestions}>
38
+ {(suggestion) => (
39
+ <span class="inline-flex items-center gap-1 rounded-full border border-border pl-3 pr-1.5 py-1.5 text-sm text-foreground transition-colors hover:bg-muted">
40
+ <button
41
+ type="button"
42
+ onClick={() => props.onSelect?.(suggestion)}
43
+ class="outline-none focus-visible:ring-2 focus-visible:ring-ring/30 rounded-sm"
44
+ >
45
+ {suggestion}
46
+ </button>
47
+ <Show when={props.onDismiss}>
48
+ <button
49
+ type="button"
50
+ aria-label={`Dismiss "${suggestion}"`}
51
+ onClick={(ev) => {
52
+ ev.stopPropagation()
53
+ props.onDismiss?.(suggestion)
54
+ }}
55
+ class="rounded-full p-0.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
56
+ >
57
+ <X class="h-3 w-3" />
58
+ </button>
59
+ </Show>
60
+ </span>
61
+ )}
62
+ </For>
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,137 @@
1
+ // ToolCallTimeline — vertical timeline (à la Timeline.tsx's connector line +
2
+ // dot idiom) where each node is an AI tool call, with a Collapse-style
3
+ // details panel for args/result.
4
+ import { Check, Loader2, X } from 'lucide-solid'
5
+ import { type JSX, For, Show, createSignal } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+
9
+ /** Status of a single tool call rendered by {@link ToolCallTimeline}. */
10
+ export type ToolCallStatus = 'pending' | 'success' | 'error'
11
+
12
+ /** A single tool invocation rendered by {@link ToolCallTimeline}. */
13
+ export interface ToolCall {
14
+ /** Tool/function name, rendered in mono. */
15
+ name: string
16
+ status: ToolCallStatus
17
+ /** Arguments passed to the call. Shown in a collapsible details area when present. */
18
+ args?: JSX.Element | string
19
+ /** Result returned by the call. Shown in a collapsible details area when present. */
20
+ result?: JSX.Element | string
21
+ }
22
+
23
+ export interface ToolCallTimelineProps {
24
+ calls: ToolCall[]
25
+ class?: string
26
+ }
27
+
28
+ /**
29
+ * Vertical timeline of AI tool calls: a connector line runs down the left with
30
+ * a status node per call (spinner while `pending`, check when `success`, x when
31
+ * `error`), the tool name in mono, and — when `args`/`result` are given — a
32
+ * collapsible details panel underneath.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <ToolCallTimeline
37
+ * calls={[
38
+ * { name: 'search_docs', status: 'success', args: 'query: "refunds"', result: '3 matches' },
39
+ * { name: 'update_ticket', status: 'pending' },
40
+ * ]}
41
+ * />
42
+ * ```
43
+ */
44
+ export function ToolCallTimeline(props: ToolCallTimelineProps): JSX.Element {
45
+ return (
46
+ <ol class={cn('relative flex flex-col gap-4', props.class)}>
47
+ <For each={props.calls}>
48
+ {(call, index) => (
49
+ <li class="relative flex gap-3 pl-1">
50
+ <Show when={index() < props.calls.length - 1}>
51
+ <span
52
+ aria-hidden="true"
53
+ class="absolute left-[calc(0.5rem+2px)] top-6 -bottom-4 w-px -translate-x-1/2 bg-border"
54
+ />
55
+ </Show>
56
+ <StatusNode status={call.status} />
57
+ <div class="min-w-0 flex-1 pb-0.5">
58
+ <p class="pt-0.5 font-mono text-sm text-foreground">{call.name}</p>
59
+ <Show when={call.args !== undefined || call.result !== undefined}>
60
+ <ToolCallDetails args={call.args} result={call.result} />
61
+ </Show>
62
+ </div>
63
+ </li>
64
+ )}
65
+ </For>
66
+ </ol>
67
+ )
68
+ }
69
+
70
+ const STATUS_TONE: Record<ToolCallStatus, string> = {
71
+ pending: 'border-border bg-muted text-muted-foreground',
72
+ success: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-500',
73
+ error: 'border-destructive/30 bg-destructive/15 text-destructive',
74
+ }
75
+
76
+ function StatusNode(props: { status: ToolCallStatus }): JSX.Element {
77
+ return (
78
+ <span
79
+ aria-hidden="true"
80
+ class={cn(
81
+ 'relative z-[1] mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border',
82
+ STATUS_TONE[props.status],
83
+ )}
84
+ >
85
+ <Show when={props.status === 'pending'}>
86
+ <Loader2 class="h-3 w-3 animate-spin motion-reduce:animate-none" />
87
+ </Show>
88
+ <Show when={props.status === 'success'}>
89
+ <Check class="h-3 w-3" />
90
+ </Show>
91
+ <Show when={props.status === 'error'}>
92
+ <X class="h-3 w-3" />
93
+ </Show>
94
+ </span>
95
+ )
96
+ }
97
+
98
+ // Reuses Collapse.tsx's grid-rows transition idiom for the args/result panel.
99
+ function ToolCallDetails(props: { args?: JSX.Element | string; result?: JSX.Element | string }): JSX.Element {
100
+ const [open, setOpen] = createSignal(false)
101
+
102
+ return (
103
+ <div class="mt-1">
104
+ <button
105
+ type="button"
106
+ aria-expanded={open()}
107
+ onClick={() => setOpen(!open())}
108
+ class="text-xs font-medium text-muted-foreground hover:text-foreground"
109
+ >
110
+ {open() ? 'Hide details' : 'Show details'}
111
+ </button>
112
+ <div
113
+ class={cn(
114
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
115
+ open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
116
+ )}
117
+ >
118
+ <div class="overflow-hidden">
119
+ <div class="mt-1.5 space-y-1.5 rounded-lg border border-border bg-muted/40 p-2 text-xs text-muted-foreground">
120
+ <Show when={props.args !== undefined}>
121
+ <div>
122
+ <p class="font-medium text-foreground/80">Args</p>
123
+ <p class="whitespace-pre-wrap font-mono">{props.args}</p>
124
+ </div>
125
+ </Show>
126
+ <Show when={props.result !== undefined}>
127
+ <div>
128
+ <p class="font-medium text-foreground/80">Result</p>
129
+ <p class="whitespace-pre-wrap font-mono">{props.result}</p>
130
+ </div>
131
+ </Show>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ )
137
+ }