@a4ui/core 0.32.0 → 0.33.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 +95 -0
- package/dist/full.css +95 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +5107 -4227
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/Globe.d.ts +44 -0
- package/dist/ui/ModelPicker.d.ts +38 -0
- package/dist/ui/ReasoningTrace.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/UsageMeter.d.ts +26 -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 +24 -1
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/Globe.tsx +317 -0
- package/src/ui/ModelPicker.tsx +119 -0
- package/src/ui/ReasoningTrace.tsx +83 -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/UsageMeter.tsx +71 -0
- package/src/ui/WorldMap.tsx +191 -0
package/src/ui/Globe.tsx
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// A dotted 3D globe rendered on a single <canvas> with plain Canvas 2D — no
|
|
2
|
+
// three.js, no WebGL. The sphere's dots come from a Fibonacci sphere
|
|
3
|
+
// (deterministic, evenly distributed — no Math.random so the dot field is
|
|
4
|
+
// stable across renders/SSR hydration). Each frame: rotate every point around
|
|
5
|
+
// the Y axis, project orthographically (drop z), keep only front-facing
|
|
6
|
+
// points (z > 0), and scale alpha/size by depth for a cheap "3D" read.
|
|
7
|
+
// Markers/arcs project through the same pipeline so they stay glued to the
|
|
8
|
+
// sphere as it spins. Colors are read from the live theme tokens via
|
|
9
|
+
// getComputedStyle (canvas can't consume `hsl(var(--x))` directly). Follows
|
|
10
|
+
// SpaceBackground's onMount/onCleanup idiom: bind listeners into a cleanups
|
|
11
|
+
// array, cancel the rAF, tear everything down on unmount.
|
|
12
|
+
import { createEffect, onCleanup, onMount, type JSX } from 'solid-js'
|
|
13
|
+
|
|
14
|
+
import { cn } from '../lib/cn'
|
|
15
|
+
import { motionReduced } from '../lib/motion'
|
|
16
|
+
|
|
17
|
+
export interface GlobeMarker {
|
|
18
|
+
lat: number
|
|
19
|
+
lng: number
|
|
20
|
+
label?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GlobeArc {
|
|
24
|
+
/** [lat, lng] */
|
|
25
|
+
from: [number, number]
|
|
26
|
+
/** [lat, lng] */
|
|
27
|
+
to: [number, number]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GlobeProps {
|
|
31
|
+
markers?: GlobeMarker[]
|
|
32
|
+
arcs?: GlobeArc[]
|
|
33
|
+
/** Canvas size in CSS px (square). @default 400 */
|
|
34
|
+
size?: number
|
|
35
|
+
/** Spin automatically. @default true (ignored under reduced motion). */
|
|
36
|
+
autoRotate?: boolean
|
|
37
|
+
class?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Vec3 {
|
|
41
|
+
x: number
|
|
42
|
+
y: number
|
|
43
|
+
z: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Projected {
|
|
47
|
+
x: number
|
|
48
|
+
y: number
|
|
49
|
+
z: number
|
|
50
|
+
front: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DOT_COUNT = 900
|
|
54
|
+
const ROTATE_SPEED = 0.12 // rad/s
|
|
55
|
+
const DRAG_SPEED = 0.012 // rad per px of pointer movement
|
|
56
|
+
|
|
57
|
+
// Fibonacci sphere: evenly-spaced points on a unit sphere, fully deterministic
|
|
58
|
+
// (no RNG) so the dot field is stable across renders and reproducible in tests.
|
|
59
|
+
function fibonacciSphere(count: number): Vec3[] {
|
|
60
|
+
const points: Vec3[] = []
|
|
61
|
+
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
|
|
62
|
+
for (let i = 0; i < count; i++) {
|
|
63
|
+
const y = 1 - (i / (count - 1)) * 2 // 1..-1
|
|
64
|
+
const radius = Math.sqrt(Math.max(0, 1 - y * y))
|
|
65
|
+
const theta = goldenAngle * i
|
|
66
|
+
points.push({ x: Math.cos(theta) * radius, y, z: Math.sin(theta) * radius })
|
|
67
|
+
}
|
|
68
|
+
return points
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SPHERE_POINTS = fibonacciSphere(DOT_COUNT)
|
|
72
|
+
|
|
73
|
+
function latLngToVec3(lat: number, lng: number): Vec3 {
|
|
74
|
+
const latRad = (lat * Math.PI) / 180
|
|
75
|
+
const lngRad = (lng * Math.PI) / 180
|
|
76
|
+
return {
|
|
77
|
+
x: Math.cos(latRad) * Math.sin(lngRad),
|
|
78
|
+
y: Math.sin(latRad),
|
|
79
|
+
z: Math.cos(latRad) * Math.cos(lngRad),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Rotate around the Y axis and project orthographically to a `radius`-sized
|
|
84
|
+
// disc centered at (cx, cy). z stays in [-1, 1] (front-facing when > 0).
|
|
85
|
+
function project(p: Vec3, angle: number, radius: number, cx: number, cy: number): Projected {
|
|
86
|
+
const cos = Math.cos(angle)
|
|
87
|
+
const sin = Math.sin(angle)
|
|
88
|
+
const x = p.x * cos + p.z * sin
|
|
89
|
+
const z = -p.x * sin + p.z * cos
|
|
90
|
+
const y = p.y
|
|
91
|
+
return { x: cx + x * radius, y: cy - y * radius, z, front: z > 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveColor(canvas: HTMLCanvasElement, token: string, fallback: string): string {
|
|
95
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim()
|
|
96
|
+
return raw ? `hsl(${raw})` : fallback
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A dotted 3D globe drawn on `<canvas>` with plain Canvas 2D (no three.js,
|
|
101
|
+
* no WebGL): dots come from a deterministic Fibonacci-sphere distribution,
|
|
102
|
+
* rotate around the Y axis, and project orthographically with depth-scaled
|
|
103
|
+
* alpha/size. `markers` render as brighter glowing dots (title = label);
|
|
104
|
+
* `arcs` draw as outward-bulging curves between two front-facing markers,
|
|
105
|
+
* with a small traveling highlight. Auto-rotates unless `autoRotate={false}`;
|
|
106
|
+
* drag with the pointer to spin manually. Renders a single static frame
|
|
107
|
+
* under `motionReduced()` (no auto-rotation; drag still works).
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```tsx
|
|
111
|
+
* <Globe
|
|
112
|
+
* size={360}
|
|
113
|
+
* markers={[
|
|
114
|
+
* { lat: 40.7, lng: -74.0, label: 'New York' },
|
|
115
|
+
* { lat: 35.7, lng: 139.7, label: 'Tokyo' },
|
|
116
|
+
* ]}
|
|
117
|
+
* arcs={[{ from: [40.7, -74.0], to: [35.7, 139.7] }]}
|
|
118
|
+
* />
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function Globe(props: GlobeProps): JSX.Element {
|
|
122
|
+
let canvas!: HTMLCanvasElement
|
|
123
|
+
const size = () => props.size ?? 400
|
|
124
|
+
|
|
125
|
+
onMount(() => {
|
|
126
|
+
const ctx = canvas.getContext('2d')
|
|
127
|
+
if (!ctx) return
|
|
128
|
+
|
|
129
|
+
const cleanups: Array<() => void> = []
|
|
130
|
+
const bind = (
|
|
131
|
+
target: EventTarget,
|
|
132
|
+
type: string,
|
|
133
|
+
handler: EventListener,
|
|
134
|
+
opts?: AddEventListenerOptions,
|
|
135
|
+
) => {
|
|
136
|
+
target.addEventListener(type, handler, opts)
|
|
137
|
+
cleanups.push(() => target.removeEventListener(type, handler, opts))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let angle = 0
|
|
141
|
+
let dragging = false
|
|
142
|
+
let lastX = 0
|
|
143
|
+
let rafId = 0
|
|
144
|
+
let lastTs = 0
|
|
145
|
+
// Screen-space marker positions from the last draw, for hover hit-testing
|
|
146
|
+
// (cheap: only a handful of markers, refreshed every frame anyway).
|
|
147
|
+
let markerHits: Array<{ x: number; y: number; label: string }> = []
|
|
148
|
+
|
|
149
|
+
const draw = (): void => {
|
|
150
|
+
const dpr = window.devicePixelRatio || 1
|
|
151
|
+
const cssSize = size()
|
|
152
|
+
const radius = cssSize * 0.42
|
|
153
|
+
const cx = cssSize / 2
|
|
154
|
+
const cy = cssSize / 2
|
|
155
|
+
|
|
156
|
+
if (canvas.width !== cssSize * dpr || canvas.height !== cssSize * dpr) {
|
|
157
|
+
canvas.width = cssSize * dpr
|
|
158
|
+
canvas.height = cssSize * dpr
|
|
159
|
+
canvas.style.width = `${cssSize}px`
|
|
160
|
+
canvas.style.height = `${cssSize}px`
|
|
161
|
+
}
|
|
162
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
163
|
+
ctx.clearRect(0, 0, cssSize, cssSize)
|
|
164
|
+
|
|
165
|
+
const dotColor = resolveColor(canvas, '--foreground', '210 20% 90%')
|
|
166
|
+
const markerColor = resolveColor(canvas, '--accent', '200 90% 60%')
|
|
167
|
+
const arcColor = resolveColor(canvas, '--primary', '250 80% 65%')
|
|
168
|
+
|
|
169
|
+
// Base sphere dots, back-to-front isn't needed (no overlap testing) —
|
|
170
|
+
// just skip anything facing away from the viewer.
|
|
171
|
+
for (const p of SPHERE_POINTS) {
|
|
172
|
+
const proj = project(p, angle, radius, cx, cy)
|
|
173
|
+
if (!proj.front) continue
|
|
174
|
+
const depth = proj.z // 0..1
|
|
175
|
+
ctx.beginPath()
|
|
176
|
+
ctx.fillStyle = dotColor
|
|
177
|
+
ctx.globalAlpha = 0.15 + depth * 0.55
|
|
178
|
+
ctx.arc(proj.x, proj.y, 0.6 + depth * 1.2, 0, Math.PI * 2)
|
|
179
|
+
ctx.fill()
|
|
180
|
+
}
|
|
181
|
+
ctx.globalAlpha = 1
|
|
182
|
+
|
|
183
|
+
// Arcs: only drawn when both endpoints are currently front-facing.
|
|
184
|
+
const arcs = props.arcs ?? []
|
|
185
|
+
for (const arc of arcs) {
|
|
186
|
+
const from = project(latLngToVec3(arc.from[0], arc.from[1]), angle, radius, cx, cy)
|
|
187
|
+
const to = project(latLngToVec3(arc.to[0], arc.to[1]), angle, radius, cx, cy)
|
|
188
|
+
if (!from.front || !to.front) continue
|
|
189
|
+
|
|
190
|
+
const mx = (from.x + to.x) / 2
|
|
191
|
+
const my = (from.y + to.y) / 2
|
|
192
|
+
// Bulge outward from the sphere center, proportional to the chord length.
|
|
193
|
+
const dx = mx - cx
|
|
194
|
+
const dy = my - cy
|
|
195
|
+
const dist = Math.hypot(dx, dy) || 1
|
|
196
|
+
const chord = Math.hypot(to.x - from.x, to.y - from.y)
|
|
197
|
+
const bulge = chord * 0.35
|
|
198
|
+
const ctrlX = mx + (dx / dist) * bulge
|
|
199
|
+
const ctrlY = my + (dy / dist) * bulge
|
|
200
|
+
|
|
201
|
+
ctx.beginPath()
|
|
202
|
+
ctx.strokeStyle = arcColor
|
|
203
|
+
ctx.globalAlpha = 0.55
|
|
204
|
+
ctx.lineWidth = 1.4
|
|
205
|
+
ctx.moveTo(from.x, from.y)
|
|
206
|
+
ctx.quadraticCurveTo(ctrlX, ctrlY, to.x, to.y)
|
|
207
|
+
ctx.stroke()
|
|
208
|
+
ctx.globalAlpha = 1
|
|
209
|
+
|
|
210
|
+
// Traveling highlight: a bright dot eased along the same quadratic,
|
|
211
|
+
// looping with the wall clock so it doesn't need its own rAF state.
|
|
212
|
+
if (!motionReduced()) {
|
|
213
|
+
const t = (performance.now() / 1600) % 1
|
|
214
|
+
const it = 1 - t
|
|
215
|
+
const hx = it * it * from.x + 2 * it * t * ctrlX + t * t * to.x
|
|
216
|
+
const hy = it * it * from.y + 2 * it * t * ctrlY + t * t * to.y
|
|
217
|
+
ctx.beginPath()
|
|
218
|
+
ctx.fillStyle = markerColor
|
|
219
|
+
ctx.arc(hx, hy, 2.4, 0, Math.PI * 2)
|
|
220
|
+
ctx.fill()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Markers: brighter glowing dots, only when front-facing.
|
|
225
|
+
const markers = props.markers ?? []
|
|
226
|
+
const hits: typeof markerHits = []
|
|
227
|
+
for (const marker of markers) {
|
|
228
|
+
const proj = project(latLngToVec3(marker.lat, marker.lng), angle, radius, cx, cy)
|
|
229
|
+
if (!proj.front) continue
|
|
230
|
+
ctx.beginPath()
|
|
231
|
+
ctx.fillStyle = markerColor
|
|
232
|
+
ctx.shadowColor = markerColor
|
|
233
|
+
ctx.shadowBlur = 8
|
|
234
|
+
ctx.arc(proj.x, proj.y, 3, 0, Math.PI * 2)
|
|
235
|
+
ctx.fill()
|
|
236
|
+
ctx.shadowBlur = 0
|
|
237
|
+
if (marker.label) hits.push({ x: proj.x, y: proj.y, label: marker.label })
|
|
238
|
+
}
|
|
239
|
+
markerHits = hits
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const tick = (ts: number): void => {
|
|
243
|
+
const dt = lastTs ? (ts - lastTs) / 1000 : 0
|
|
244
|
+
lastTs = ts
|
|
245
|
+
if (!dragging && !motionReduced() && props.autoRotate !== false) {
|
|
246
|
+
angle += ROTATE_SPEED * dt
|
|
247
|
+
}
|
|
248
|
+
draw()
|
|
249
|
+
// Keep animating while auto-rotating, or to refresh the traveling arc
|
|
250
|
+
// highlight — both are no-ops under reduced motion (frozen frame).
|
|
251
|
+
if (!motionReduced()) rafId = requestAnimationFrame(tick)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (motionReduced()) {
|
|
255
|
+
draw() // single static frame
|
|
256
|
+
} else {
|
|
257
|
+
rafId = requestAnimationFrame(tick)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Pointer drag to spin manually — works even under reduced motion.
|
|
261
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
262
|
+
dragging = true
|
|
263
|
+
lastX = e.clientX
|
|
264
|
+
canvas.setPointerCapture(e.pointerId)
|
|
265
|
+
}
|
|
266
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
267
|
+
if (!dragging) {
|
|
268
|
+
// Hover hit-test against the last drawn marker positions → native tooltip.
|
|
269
|
+
const rect = canvas.getBoundingClientRect()
|
|
270
|
+
const px = e.clientX - rect.left
|
|
271
|
+
const py = e.clientY - rect.top
|
|
272
|
+
const hit = markerHits.find((m) => Math.hypot(m.x - px, m.y - py) <= 6)
|
|
273
|
+
canvas.title = hit?.label ?? ''
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
const dx = e.clientX - lastX
|
|
277
|
+
lastX = e.clientX
|
|
278
|
+
angle += dx * DRAG_SPEED
|
|
279
|
+
if (motionReduced()) draw() // rAF loop is off; redraw explicitly per move
|
|
280
|
+
}
|
|
281
|
+
const onPointerUp = (e: PointerEvent): void => {
|
|
282
|
+
dragging = false
|
|
283
|
+
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
|
|
284
|
+
}
|
|
285
|
+
bind(canvas, 'pointerdown', onPointerDown as EventListener)
|
|
286
|
+
bind(canvas, 'pointermove', onPointerMove as EventListener)
|
|
287
|
+
bind(canvas, 'pointerup', onPointerUp as EventListener)
|
|
288
|
+
bind(canvas, 'pointercancel', onPointerUp as EventListener)
|
|
289
|
+
|
|
290
|
+
// Re-render (static frame) if props change while reduced motion holds the
|
|
291
|
+
// loop frozen — otherwise the rAF loop already picks up prop changes.
|
|
292
|
+
createEffect(() => {
|
|
293
|
+
// Track reactive deps: size + reference identity of markers/arcs.
|
|
294
|
+
void size()
|
|
295
|
+
void props.markers
|
|
296
|
+
void props.arcs
|
|
297
|
+
void props.autoRotate
|
|
298
|
+
if (motionReduced()) draw()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
onCleanup(() => {
|
|
302
|
+
if (rafId) cancelAnimationFrame(rafId)
|
|
303
|
+
cleanups.forEach((fn) => fn())
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<canvas
|
|
309
|
+
ref={canvas}
|
|
310
|
+
class={cn('touch-none rounded-full select-none', props.class)}
|
|
311
|
+
width={size()}
|
|
312
|
+
height={size()}
|
|
313
|
+
role="img"
|
|
314
|
+
aria-label="Interactive globe"
|
|
315
|
+
/>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// AI model picker — dropdown trigger (icon + name + chevron) over a menu of
|
|
2
|
+
// models (icon + name + description + badge), selected one checkmarked.
|
|
3
|
+
// Built on Kobalte's DropdownMenu (same primitive as Dropdown.tsx), using its
|
|
4
|
+
// RadioGroup/RadioItem for single-select keyboard navigation and a11y
|
|
5
|
+
// (role="menuitemradio", aria-checked) — Dropdown.tsx's own item API only
|
|
6
|
+
// supports a plain string label, not this richer per-option content.
|
|
7
|
+
import { DropdownMenu } from '@kobalte/core/dropdown-menu'
|
|
8
|
+
import { Check, ChevronDown } from 'lucide-solid'
|
|
9
|
+
import type { JSX } from 'solid-js'
|
|
10
|
+
import { For, Show, createSignal, untrack } from 'solid-js'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../lib/cn'
|
|
13
|
+
|
|
14
|
+
/** A single selectable model in a {@link ModelPicker} menu. */
|
|
15
|
+
export interface ModelOption {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
icon?: JSX.Element
|
|
19
|
+
description?: string
|
|
20
|
+
badge?: JSX.Element
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ModelPickerProps {
|
|
24
|
+
models: ModelOption[]
|
|
25
|
+
/** Controlled selected model id. Omit and use `defaultValue` for uncontrolled use. */
|
|
26
|
+
value?: string
|
|
27
|
+
/** Initial selected model id when uncontrolled. Ignored if `value` is passed. */
|
|
28
|
+
defaultValue?: string
|
|
29
|
+
onChange?: (id: string) => void
|
|
30
|
+
class?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dropdown trigger showing the selected model's icon + name + a chevron; the
|
|
35
|
+
* menu lists every model with icon, name, description, and optional badge,
|
|
36
|
+
* checkmarking the current selection. Controlled via `value`/`onChange` or
|
|
37
|
+
* uncontrolled via `defaultValue`, and fully keyboard-navigable (arrow keys,
|
|
38
|
+
* typeahead, Escape) via Kobalte's `DropdownMenu`.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <ModelPicker
|
|
43
|
+
* models={[
|
|
44
|
+
* { id: 'sonnet', name: 'Claude Sonnet', description: 'Balanced speed and quality' },
|
|
45
|
+
* { id: 'opus', name: 'Claude Opus', description: 'Most capable', badge: <Badge tone="info">New</Badge> },
|
|
46
|
+
* ]}
|
|
47
|
+
* defaultValue="sonnet"
|
|
48
|
+
* onChange={(id) => console.log('selected', id)}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function ModelPicker(props: ModelPickerProps): JSX.Element {
|
|
53
|
+
// Initial value only: read once (untracked), by design — this seeds the
|
|
54
|
+
// uncontrolled fallback and must not re-run when `models`/`defaultValue` change later.
|
|
55
|
+
const [internal, setInternal] = createSignal(untrack(() => props.defaultValue ?? props.models[0]?.id))
|
|
56
|
+
const selectedId = () => props.value ?? internal()
|
|
57
|
+
const selectedModel = () => props.models.find((m) => m.id === selectedId())
|
|
58
|
+
|
|
59
|
+
const handleChange = (id: string) => {
|
|
60
|
+
if (props.value === undefined) setInternal(id)
|
|
61
|
+
props.onChange?.(id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<DropdownMenu preventScroll={false}>
|
|
66
|
+
<DropdownMenu.Trigger
|
|
67
|
+
class={cn(
|
|
68
|
+
'inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors a4-field',
|
|
69
|
+
props.class,
|
|
70
|
+
)}
|
|
71
|
+
aria-label="Select model"
|
|
72
|
+
>
|
|
73
|
+
<Show when={selectedModel()?.icon}>
|
|
74
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
75
|
+
{selectedModel()?.icon}
|
|
76
|
+
</span>
|
|
77
|
+
</Show>
|
|
78
|
+
<span class="truncate font-medium">{selectedModel()?.name}</span>
|
|
79
|
+
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
80
|
+
</DropdownMenu.Trigger>
|
|
81
|
+
<DropdownMenu.Portal>
|
|
82
|
+
<DropdownMenu.Content class="z-50 min-w-64 overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
|
|
83
|
+
<DropdownMenu.RadioGroup value={selectedId()} onChange={handleChange}>
|
|
84
|
+
<For each={props.models}>
|
|
85
|
+
{(model) => (
|
|
86
|
+
<DropdownMenu.RadioItem
|
|
87
|
+
value={model.id}
|
|
88
|
+
class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
89
|
+
>
|
|
90
|
+
<Show when={model.icon}>
|
|
91
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
92
|
+
{model.icon}
|
|
93
|
+
</span>
|
|
94
|
+
</Show>
|
|
95
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
96
|
+
<DropdownMenu.ItemLabel class="truncate text-sm text-foreground">
|
|
97
|
+
{model.name}
|
|
98
|
+
</DropdownMenu.ItemLabel>
|
|
99
|
+
<Show when={model.description}>
|
|
100
|
+
<DropdownMenu.ItemDescription class="truncate text-xs text-muted-foreground">
|
|
101
|
+
{model.description}
|
|
102
|
+
</DropdownMenu.ItemDescription>
|
|
103
|
+
</Show>
|
|
104
|
+
</span>
|
|
105
|
+
<Show when={model.badge}>{model.badge}</Show>
|
|
106
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-primary">
|
|
107
|
+
<Show when={model.id === selectedId()}>
|
|
108
|
+
<Check class="h-4 w-4" />
|
|
109
|
+
</Show>
|
|
110
|
+
</span>
|
|
111
|
+
</DropdownMenu.RadioItem>
|
|
112
|
+
)}
|
|
113
|
+
</For>
|
|
114
|
+
</DropdownMenu.RadioGroup>
|
|
115
|
+
</DropdownMenu.Content>
|
|
116
|
+
</DropdownMenu.Portal>
|
|
117
|
+
</DropdownMenu>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ReasoningTrace — collapsible "thinking" panel for AI surfaces. Mirrors
|
|
2
|
+
// Collapse.tsx's header/panel idiom (grid-rows transition, chevron rotate)
|
|
3
|
+
// but owns its own open state (seeded from `defaultOpen`) and adds a
|
|
4
|
+
// streaming pulse indicator for in-flight reasoning.
|
|
5
|
+
import { Brain, ChevronDown } from 'lucide-solid'
|
|
6
|
+
import { type JSX, Show, createSignal } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface ReasoningTraceProps {
|
|
11
|
+
/** Reasoning body content; takes precedence over `text` when both are given. */
|
|
12
|
+
children?: JSX.Element
|
|
13
|
+
/** Plain-text reasoning body, rendered with preserved whitespace. */
|
|
14
|
+
text?: string
|
|
15
|
+
/** Header label. Defaults to `'Reasoning'`. */
|
|
16
|
+
label?: JSX.Element
|
|
17
|
+
/** Whether the panel starts expanded. Defaults to `false`. */
|
|
18
|
+
defaultOpen?: boolean
|
|
19
|
+
/** Show a pulsing dot in the header to indicate reasoning is still streaming. */
|
|
20
|
+
streaming?: boolean
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collapsible panel for surfacing a model's intermediate "thinking"/reasoning
|
|
26
|
+
* trace. The header is a toggle button (brain icon + label + rotating
|
|
27
|
+
* chevron, plus a pulsing dot while `streaming`); the body is a muted,
|
|
28
|
+
* smaller-text block showing `text` or `children` with whitespace preserved.
|
|
29
|
+
* Collapsed by default unless `defaultOpen`. Height/opacity transition,
|
|
30
|
+
* instant under reduced motion.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <ReasoningTrace streaming text={reasoningSoFar()} />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function ReasoningTrace(props: ReasoningTraceProps): JSX.Element {
|
|
38
|
+
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div class={cn('rounded-xl border border-border bg-card', props.class)}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-expanded={open()}
|
|
45
|
+
onClick={() => setOpen(!open())}
|
|
46
|
+
class="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
|
47
|
+
>
|
|
48
|
+
<Brain class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
49
|
+
<span class="flex-1 text-left">{props.label ?? 'Reasoning'}</span>
|
|
50
|
+
<Show when={props.streaming}>
|
|
51
|
+
<span class="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
|
52
|
+
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75 motion-reduce:animate-none" />
|
|
53
|
+
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
|
54
|
+
</span>
|
|
55
|
+
</Show>
|
|
56
|
+
<ChevronDown
|
|
57
|
+
class={cn(
|
|
58
|
+
'h-4 w-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
|
59
|
+
open() && 'rotate-180',
|
|
60
|
+
)}
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
</button>
|
|
64
|
+
<div
|
|
65
|
+
class={cn(
|
|
66
|
+
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
67
|
+
open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
<div class="overflow-hidden">
|
|
71
|
+
<div
|
|
72
|
+
class={cn(
|
|
73
|
+
'whitespace-pre-wrap px-3 pb-3 text-xs leading-relaxed text-muted-foreground transition-opacity duration-200 motion-reduce:transition-none',
|
|
74
|
+
open() ? 'opacity-100' : 'opacity-0',
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{props.children ?? props.text}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|