@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.
- package/dist/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +446 -316
- package/dist/elements.css +278 -0
- package/dist/full.css +278 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +6423 -4195
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/Globe.d.ts +44 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/ModelPicker.d.ts +38 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +48 -1
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/Globe.tsx +317 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/ModelPicker.tsx +119 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
- 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
|
+
}
|