@workerdeck/ui 0.9.0 → 0.11.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 (61) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +610 -87
  11. package/build/index.mjs +6 -5104
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -91
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +495 -30
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -0,0 +1,148 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { OpenFile } from '@workerdeck/react'
3
+ import { currentText, isDirty } from '@workerdeck/react'
4
+ import { FileWarning, TriangleAlert } from 'lucide-react'
5
+ import { cn } from '../../lib/utils.ts'
6
+ import { Button } from '../ui/Button.tsx'
7
+ import { Spinner } from '../ui/Spinner.tsx'
8
+ import { CodeEditor } from './CodeEditor.tsx'
9
+
10
+ export interface FileViewerProps {
11
+ file: OpenFile | undefined
12
+ /** From `/fs/roots`. False renders the editor read-only rather than letting
13
+ * someone type into a file this gateway will refuse to write. */
14
+ canWrite?: boolean
15
+ onChange?: (path: string, content: string) => void
16
+ onSave?: (path: string) => void
17
+ /** Discard this tab's edits — local only, no re-read. */
18
+ onRevert?: (path: string) => void
19
+ /** Take the version on disk, discarding this tab's edits. */
20
+ onReload?: (path: string) => void
21
+ /** Take this tab's version, over whatever is on disk now. */
22
+ onOverwrite?: (path: string) => void
23
+ onDismissConflict?: (path: string) => void
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * The focused file: Monaco, plus the states a file can be in that are not
29
+ * "here is some text".
30
+ *
31
+ * No path row — the tab's tooltip carries the path and the size, and a line of
32
+ * monospace above every file is chrome that never earns its height.
33
+ */
34
+ export function FileViewer({
35
+ file,
36
+ canWrite,
37
+ onChange,
38
+ onSave,
39
+ onRevert,
40
+ onReload,
41
+ onOverwrite,
42
+ onDismissConflict,
43
+ className,
44
+ }: FileViewerProps) {
45
+ if (!file) return null
46
+
47
+ return (
48
+ <div
49
+ data-slot='file-viewer'
50
+ className={cn('flex min-h-0 min-w-0 flex-1 flex-col bg-bg', className)}>
51
+ {/* The one failure with a choice attached, so it gets a bar rather than a
52
+ toast: the agent rewrote this file while it was open, and which
53
+ version wins is not something to decide on the user's behalf. */}
54
+ {file.conflict ? (
55
+ <div className='flex shrink-0 flex-wrap items-center gap-2 border-b border-warning/40 bg-warning-bg px-3 py-2'>
56
+ <TriangleAlert className='size-3.5 shrink-0 text-warning' />
57
+ <span className='min-w-0 flex-1 text-body-sm text-warning'>
58
+ This file changed on disk since you opened it.
59
+ </span>
60
+ <Button variant='outline' size='xs' onClick={() => onReload?.(file.path)}>
61
+ Use the version on disk
62
+ </Button>
63
+ <Button variant='outline' size='xs' onClick={() => onOverwrite?.(file.path)}>
64
+ Keep mine
65
+ </Button>
66
+ <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
67
+ Dismiss
68
+ </Button>
69
+ </div>
70
+ ) : file.saveError ? (
71
+ <div className='flex shrink-0 items-center gap-2 border-b border-danger/40 bg-danger-bg px-3 py-2'>
72
+ <TriangleAlert className='size-3.5 shrink-0 text-danger' />
73
+ <span className='min-w-0 flex-1 text-body-sm text-danger'>{file.saveError}</span>
74
+ <Button variant='ghost' size='xs' onClick={() => onDismissConflict?.(file.path)}>
75
+ Dismiss
76
+ </Button>
77
+ </div>
78
+ ) : null}
79
+
80
+ {file.status === 'loading' ? (
81
+ <Centred>
82
+ <Spinner className='size-4 text-fg-4' />
83
+ </Centred>
84
+ ) : file.status === 'error' ? (
85
+ <Centred>
86
+ <TriangleAlert className='size-4 text-danger' />
87
+ <p className='text-body-sm text-danger'>{file.error}</p>
88
+ </Centred>
89
+ ) : file.status === 'binary' ? (
90
+ <Centred>
91
+ <FileWarning className='size-4 text-fg-4' />
92
+ <p className='text-body-sm text-fg-4'>This file isn’t text.</p>
93
+ {/* Said out loud, because "can't show it" and "editing it here would
94
+ destroy it" are different reassurances. */}
95
+ <p className='text-label text-fg-4'>It can’t be edited here without corrupting it.</p>
96
+ </Centred>
97
+ ) : (
98
+ <CodeEditor
99
+ path={file.path}
100
+ value={currentText(file)}
101
+ readOnly={!canWrite}
102
+ onChange={onChange ? (content) => onChange(file.path, content) : undefined}
103
+ onSave={onSave ? () => onSave(file.path) : undefined}
104
+ />
105
+ )}
106
+
107
+ {/* A status strip only while there is something to say. Saving is fast
108
+ enough that a permanent row would mostly be blank. */}
109
+ {file.status === 'ready' && (file.saving || isDirty(file) || !canWrite) ? (
110
+ <div className='flex shrink-0 items-center gap-2 border-t border-border px-3 py-1'>
111
+ {file.saving ? (
112
+ <>
113
+ <Spinner className='size-3 text-fg-4' />
114
+ <span className='text-label text-fg-4'>Saving…</span>
115
+ </>
116
+ ) : !canWrite ? (
117
+ <span className='text-label text-fg-4'>
118
+ Read-only — this gateway doesn’t allow writes.
119
+ </span>
120
+ ) : (
121
+ <>
122
+ <span className='text-label text-fg-3'>Unsaved changes</span>
123
+ <span className='flex-1' />
124
+ {/* Revert, not reload: discard *my* edits and go back to what
125
+ this tab read. Re-reading is the conflict bar's job, and it is
126
+ a different question. */}
127
+ <Button variant='ghost' size='xs' onClick={() => onRevert?.(file.path)}>
128
+ Revert
129
+ </Button>
130
+ <Button variant='outline' size='xs' onClick={() => onSave?.(file.path)}>
131
+ Save
132
+ <span className='ml-1 text-fg-4'>⌘S</span>
133
+ </Button>
134
+ </>
135
+ )}
136
+ </div>
137
+ ) : null}
138
+ </div>
139
+ )
140
+ }
141
+
142
+ function Centred({ children }: { children: ReactNode }) {
143
+ return (
144
+ <div className='flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-6'>
145
+ {children}
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,218 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type { WorkerDeckClient } from '@workerdeck/client'
3
+ import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
4
+ import { ChevronLeft, File, Folder, Link2, Search } from 'lucide-react'
5
+ import { Button } from '../ui/Button.tsx'
6
+ import { CodeBlock } from '../ui/CodeBlock.tsx'
7
+ import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
8
+ import { Input } from '../ui/Input.tsx'
9
+ import { Spinner } from '../ui/Spinner.tsx'
10
+ import { formatBytes } from '../../lib/format.ts'
11
+
12
+ export interface HostFilesDialogProps {
13
+ client: WorkerDeckClient
14
+ /** The session's working directory — the browser is rooted here. */
15
+ cwd: string | undefined
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ }
19
+
20
+ /**
21
+ * Browse the project the session is working in.
22
+ *
23
+ * Deliberately rooted at the session's cwd rather than at the server's
24
+ * `hostFiles.roots`: the roots are the *security* boundary (the server enforces
25
+ * them on every request), but what someone wants while watching an agent is this
26
+ * project's tree. Read-only — writing is a separate server opt-in and not
27
+ * something a session viewer should be doing behind the agent's back.
28
+ */
29
+ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDialogProps) {
30
+ const [path, setPath] = useState<string | undefined>(cwd)
31
+ const [entries, setEntries] = useState<HostDirEntry[]>([])
32
+ const [truncated, setTruncated] = useState(false)
33
+ const [query, setQuery] = useState('')
34
+ const [matches, setMatches] = useState<HostFileMatch[] | undefined>()
35
+ const [file, setFile] = useState<{ path: string; content: string; bytes: number } | undefined>()
36
+ const [loading, setLoading] = useState(false)
37
+ const [error, setError] = useState<string | undefined>()
38
+
39
+ const list = useCallback(
40
+ async (target: string) => {
41
+ setLoading(true)
42
+ setError(undefined)
43
+ try {
44
+ const response = await client.listHostDir(target)
45
+ setPath(response.path)
46
+ setEntries(response.entries)
47
+ setTruncated(response.truncated ?? false)
48
+ } catch (e) {
49
+ setError(e instanceof Error ? e.message : 'Could not read that directory')
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ },
54
+ [client],
55
+ )
56
+
57
+ // Opening resets the browser to the session's directory. Navigation from here
58
+ // is explicit (`list`) rather than an effect on `path`, so walking into a
59
+ // folder is one request and not two.
60
+ useEffect(() => {
61
+ if (!open) return
62
+ setFile(undefined)
63
+ setQuery('')
64
+ setMatches(undefined)
65
+ setError(undefined)
66
+ if (cwd) void list(cwd)
67
+ }, [open, cwd, list])
68
+
69
+ // Debounced, and only while there is something to search for; an empty box is
70
+ // "show me the directory again", not "search for everything".
71
+ useEffect(() => {
72
+ if (!open || !cwd) return
73
+ const q = query.trim()
74
+ if (!q) {
75
+ setMatches(undefined)
76
+ return
77
+ }
78
+ const timer = setTimeout(() => {
79
+ client
80
+ .findHostFiles(cwd, q, 40)
81
+ .then((response) => setMatches(response.matches))
82
+ .catch(() => setMatches([]))
83
+ }, 150)
84
+ return () => clearTimeout(timer)
85
+ }, [client, cwd, query, open])
86
+
87
+ const openFile = async (target: string) => {
88
+ setLoading(true)
89
+ setError(undefined)
90
+ try {
91
+ const response = await client.readHostFile(target)
92
+ setFile({
93
+ path: response.path,
94
+ bytes: response.bytes,
95
+ content:
96
+ response.encoding === 'utf8'
97
+ ? response.content
98
+ : '(binary file — not shown)',
99
+ })
100
+ } catch (e) {
101
+ setError(e instanceof Error ? e.message : 'Could not read that file')
102
+ } finally {
103
+ setLoading(false)
104
+ }
105
+ }
106
+
107
+ const parent = path && cwd && path !== cwd ? path.slice(0, path.lastIndexOf('/')) || '/' : undefined
108
+ const shown = matches ?? entries
109
+
110
+ return (
111
+ <Dialog open={open} onOpenChange={onOpenChange}>
112
+ <DialogContent size='lg'>
113
+ <DialogHeader
114
+ title={file ? file.path.split('/').pop()! : 'Files'}
115
+ description={file ? file.path : path}
116
+ actions={
117
+ file ? (
118
+ <Button variant='ghost' size='xs' onClick={() => setFile(undefined)}>
119
+ <ChevronLeft className='size-3.5' />
120
+ Back
121
+ </Button>
122
+ ) : parent ? (
123
+ <Button variant='ghost' size='xs' onClick={() => void list(parent)}>
124
+ <ChevronLeft className='size-3.5' />
125
+ Up
126
+ </Button>
127
+ ) : null
128
+ }
129
+ />
130
+ <DialogBody className='flex flex-col gap-3'>
131
+ {error ? (
132
+ <div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>{error}</div>
133
+ ) : null}
134
+
135
+ {file ? (
136
+ <>
137
+ <p className='text-label text-fg-4'>{formatBytes(file.bytes)}</p>
138
+ <CodeBlock code={file.content} label={file.path.split('/').pop()} />
139
+ </>
140
+ ) : (
141
+ <>
142
+ <div className='relative'>
143
+ <Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
144
+ <Input
145
+ value={query}
146
+ onChange={(e) => setQuery(e.target.value)}
147
+ placeholder='Search this project…'
148
+ className='pl-7'
149
+ spellCheck={false}
150
+ />
151
+ </div>
152
+ {loading && shown.length === 0 ? (
153
+ <div className='py-6 text-center'>
154
+ <Spinner className='size-4 text-fg-4' />
155
+ </div>
156
+ ) : shown.length === 0 ? (
157
+ <p className='py-6 text-center text-body-sm text-fg-4'>
158
+ {matches ? 'No matching files.' : 'This directory is empty.'}
159
+ </p>
160
+ ) : (
161
+ <ul className='flex flex-col'>
162
+ {matches
163
+ ? matches.map((match) => (
164
+ <li key={match.path}>
165
+ <button
166
+ type='button'
167
+ onClick={() => void openFile(match.path)}
168
+ className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
169
+ <File className='size-3.5 shrink-0 text-fg-4' />
170
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
171
+ {match.relative}
172
+ </span>
173
+ </button>
174
+ </li>
175
+ ))
176
+ : entries.map((entry) => (
177
+ <li key={entry.path}>
178
+ <button
179
+ type='button'
180
+ onClick={() =>
181
+ entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path)
182
+ }
183
+ className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
184
+ <EntryIcon type={entry.type} />
185
+ <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
186
+ {entry.name}
187
+ {entry.type === 'dir' ? '/' : ''}
188
+ </span>
189
+ {entry.bytes !== undefined ? (
190
+ <span className='shrink-0 text-label text-fg-4'>
191
+ {formatBytes(entry.bytes)}
192
+ </span>
193
+ ) : null}
194
+ </button>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ {truncated && !matches ? (
200
+ <p className='text-label text-fg-4'>
201
+ More entries than the server will return — use the search box.
202
+ </p>
203
+ ) : null}
204
+ </>
205
+ )}
206
+ </DialogBody>
207
+ </DialogContent>
208
+ </Dialog>
209
+ )
210
+ }
211
+
212
+ /** A symlink is reported as itself and never silently resolved — following it is
213
+ * the next request's problem, and that request is refused if it escapes the roots. */
214
+ function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
215
+ if (type === 'dir') return <Folder className='size-3.5 shrink-0 text-accent' />
216
+ if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
217
+ return <File className='size-3.5 shrink-0 text-fg-4' />
218
+ }
@@ -1,21 +1,127 @@
1
+ import { useEffect, useState } from 'react'
1
2
  import { cn } from '../../lib/utils.ts'
3
+ import { formatDuration, formatTokens } from '../../lib/format.ts'
4
+ import { LineGlyph, useLines } from './transcript-variant.tsx'
5
+
6
+ export interface LoaderProps {
7
+ /** Overrides the cycling verb — for a state that has one true name
8
+ * ("Starting session…"). */
9
+ label?: string
10
+ /** When the current run began, for the elapsed clock. Absent = no clock. */
11
+ startedAt?: number
12
+ /** Context tokens in play, shown beside the clock. */
13
+ tokens?: number
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * The frames of the working marker, and the rate they turn over.
19
+ *
20
+ * A four-pointed star growing and shrinking — it reads as *activity* at a glance
21
+ * without any of the pixel-fitting a braille or block spinner needs, and it is
22
+ * the same shape a terminal agent uses because a terminal is where this
23
+ * vocabulary comes from. ~8fps: fast enough to be alive, slow enough not to
24
+ * strobe next to streaming text.
25
+ */
26
+ const FRAMES = ['✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳']
27
+ const FRAME_MS = 120
28
+
29
+ /**
30
+ * What it says it is doing while it hasn't said anything yet. Cycled on a slow
31
+ * clock so a long turn doesn't sit under one frozen word — a still label reads
32
+ * as a hung process, which is exactly what this is meant to disprove.
33
+ */
34
+ const VERBS = [
35
+ 'Working',
36
+ 'Thinking',
37
+ 'Churning',
38
+ 'Pondering',
39
+ 'Whirring',
40
+ 'Computing',
41
+ 'Percolating',
42
+ 'Tinkering',
43
+ 'Deliberating',
44
+ 'Simmering',
45
+ 'Crunching',
46
+ 'Noodling',
47
+ ]
48
+ const VERB_MS = 4000
49
+
50
+ /** Ticks while mounted, at the spinner's rate. Mounted only while a turn is in
51
+ * flight, so nothing here runs on an idle session. */
52
+ function useFrames(animated: boolean): number {
53
+ const [frame, setFrame] = useState(0)
54
+ useEffect(() => {
55
+ if (!animated) return
56
+ const timer = setInterval(() => setFrame((f) => f + 1), FRAME_MS)
57
+ return () => clearInterval(timer)
58
+ }, [animated])
59
+ return frame
60
+ }
61
+
62
+ /** The OS-level "stop moving things" setting. A spinner is decoration; the word
63
+ * beside it carries the meaning, so honouring this costs nothing. */
64
+ function usePrefersReducedMotion(): boolean {
65
+ const [reduced, setReduced] = useState(false)
66
+ useEffect(() => {
67
+ const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
68
+ if (!query) return
69
+ setReduced(query.matches)
70
+ const onChange = () => setReduced(query.matches)
71
+ query.addEventListener('change', onChange)
72
+ return () => query.removeEventListener('change', onChange)
73
+ }, [])
74
+ return reduced
75
+ }
76
+
77
+ /**
78
+ * "The agent is working and hasn't produced output yet."
79
+ *
80
+ * `lines`: a terminal working line — animated glyph in the gutter, a verb, and
81
+ * the readings that answer "should I still be waiting?" in one parenthesis.
82
+ * `cards`: the three-dot pulse, unchanged.
83
+ */
84
+ export function Loader({ label, startedAt, tokens, className }: LoaderProps) {
85
+ const lines = useLines()
86
+ const reducedMotion = usePrefersReducedMotion()
87
+ const frame = useFrames(lines && !reducedMotion)
88
+
89
+ if (!lines) {
90
+ return (
91
+ <div
92
+ data-slot='loader'
93
+ className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
94
+ <span className='flex items-center gap-1'>
95
+ {[0, 1, 2].map((i) => (
96
+ <span
97
+ key={i}
98
+ className='size-1.5 animate-pulse rounded-full bg-fg-4'
99
+ style={{ animationDelay: `${i * 160}ms` }}
100
+ />
101
+ ))}
102
+ </span>
103
+ {label ? <span>{label}</span> : null}
104
+ </div>
105
+ )
106
+ }
107
+
108
+ // Derived from the clock rather than kept in state: the frame tick is already
109
+ // re-rendering, so the seconds and the verb come along for free.
110
+ const elapsed = startedAt === undefined ? undefined : Date.now() - startedAt
111
+ const verb =
112
+ label ?? VERBS[Math.floor((elapsed ?? 0) / VERB_MS) % VERBS.length] ?? VERBS[0]
113
+ const readings = [
114
+ elapsed !== undefined ? formatDuration(elapsed) : undefined,
115
+ tokens !== undefined ? `↓ ${formatTokens(tokens)}` : undefined,
116
+ ].filter(Boolean)
2
117
 
3
- /** Three-dot pulse shown while the assistant hasn't produced output yet. */
4
- export function Loader({ label, className }: { label?: string; className?: string }) {
5
118
  return (
6
- <div
7
- data-slot='loader'
8
- className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
9
- <span className='flex items-center gap-1'>
10
- {[0, 1, 2].map((i) => (
11
- <span
12
- key={i}
13
- className='size-1.5 animate-pulse rounded-full bg-fg-4'
14
- style={{ animationDelay: `${i * 160}ms` }}
15
- />
16
- ))}
119
+ <div data-slot='loader' className={cn('flex items-baseline gap-2', className)}>
120
+ <LineGlyph className='text-accent'>{FRAMES[frame % FRAMES.length]}</LineGlyph>
121
+ <span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-3'>
122
+ {verb}…{' '}
123
+ {readings.length ? <span className='text-label text-fg-4'>({readings.join(' · ')})</span> : null}
17
124
  </span>
18
- {label ? <span>{label}</span> : null}
19
125
  </div>
20
126
  )
21
127
  }