@workerdeck/ui 0.7.0 → 0.10.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 (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +767 -21
  3. package/build/index.mjs +3268 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +380 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +109 -12
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. package/src/lib/tool-icon.ts +74 -0
@@ -1,28 +1,75 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import type { WorkerDeckClient } from '@workerdeck/client'
3
- import { PROVIDER_PERMISSION_MODES } from '@workerdeck/protocol'
4
- import { useClaudeSession, useToolCallHost } from '@workerdeck/react'
3
+ import { PROTOCOL_VERSION } from '@workerdeck/protocol'
4
+ import {
5
+ rateLimitWindows,
6
+ useAttachments,
7
+ useClaudeSession,
8
+ useHostFileSearch,
9
+ useToolCallHost,
10
+ type ProducedFileRef,
11
+ } from '@workerdeck/react'
12
+ import {
13
+ ChartPie,
14
+ FolderTree,
15
+ Gauge,
16
+ Info,
17
+ MoreHorizontal,
18
+ Plug,
19
+ Sparkles,
20
+ TriangleAlert,
21
+ X,
22
+ } from 'lucide-react'
5
23
  import { cn } from '../../lib/utils.ts'
6
- import { Composer } from './Composer.tsx'
24
+ import { Button } from '../ui/Button.tsx'
25
+ import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
26
+ import { Composer, skillPrompt, type ComposerHandle } from './Composer.tsx'
27
+ import { ContextDialog } from './ContextDialog.tsx'
28
+ import { HostFilesDialog } from './HostFilesDialog.tsx'
29
+ import { McpDialog } from './McpDialog.tsx'
30
+ import { SkillsDialog } from './SkillsDialog.tsx'
7
31
  import { ModelSelect } from './ModelSelect.tsx'
8
32
  import { PermissionModeSelect } from './PermissionModeSelect.tsx'
9
33
  import { PermissionPrompt } from './PermissionPrompt.tsx'
10
34
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
35
+ import { SessionInfoDialog } from './SessionInfoDialog.tsx'
11
36
  import { StatusBar } from './StatusBar.tsx'
12
37
  import { Transcript } from './Transcript.tsx'
38
+ import { UsageDialog } from './UsageDialog.tsx'
13
39
 
14
40
  export interface SessionPanelProps {
15
41
  client: WorkerDeckClient
16
42
  sessionId: string | undefined
17
- /** Optional slot rendered at the top, above the status bar. */
18
- header?: ReactNode
43
+ /**
44
+ * Optional slot rendered at the top, above the status bar.
45
+ *
46
+ * Pass a **function** to take the session-actions (`⋯`) menu into your own
47
+ * chrome: it is called with the menu element, and wherever you put it is
48
+ * where it lives — the status bar then renders without it, so it never
49
+ * appears twice. Pass a plain node (or nothing) and the menu stays in the
50
+ * status bar's trailing slot.
51
+ *
52
+ * The seam exists because the menu can only be *built* here — it needs the
53
+ * capability record, the host-file verdict and the panel's own dialog state —
54
+ * but an embedder with a real header usually wants it up there with the rest
55
+ * of the session's controls, not stranded on the status line.
56
+ */
57
+ header?: ReactNode | ((slots: { actions: ReactNode }) => ReactNode)
19
58
  className?: string
20
59
  }
21
60
 
61
+ /** The panels the session surface can raise. One at a time, by identity: a bag
62
+ * of booleans would let two open at once. */
63
+ type Panel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
64
+
22
65
  /**
23
66
  * The all-in-one embeddable session surface: status bar, streaming transcript,
24
67
  * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
25
68
  * sessions.
69
+ *
70
+ * Every affordance is gated on the session's **capability record** rather than on
71
+ * the engine name — an absent capability hides the control instead of offering
72
+ * one that can only fail.
26
73
  */
27
74
  export function SessionPanel({ client, sessionId, header, className }: SessionPanelProps) {
28
75
  // Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
@@ -31,18 +78,56 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
31
78
  // — the select would just "not stick". An error channel a host can lose by omission
32
79
  // is not an error channel.
33
80
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
34
- const { state, connected, handle, send, approve, deny, interrupt, setModel, setPermissionMode } =
35
- useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
81
+ const [panel, setPanel] = useState<Panel | undefined>()
82
+ const {
83
+ state,
84
+ connection,
85
+ protocolMismatch,
86
+ models,
87
+ effectiveModel,
88
+ handle,
89
+ send,
90
+ approve,
91
+ deny,
92
+ interrupt,
93
+ setModel,
94
+ setPermissionMode,
95
+ reconnectNow,
96
+ } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
36
97
  // Callers are told to remount on a session switch, but a changed prop must not leave
37
98
  // the previous session's failure on screen.
38
99
  useEffect(() => setProtocolError(undefined), [sessionId])
100
+ // A tab that was in the background has been sitting out the reconnect backoff;
101
+ // coming back to it is exactly when waiting the rest of it out is wrong.
102
+ useEffect(() => {
103
+ const onVisible = () => {
104
+ if (document.visibilityState === 'visible') reconnectNow()
105
+ }
106
+ document.addEventListener('visibilitychange', onVisible)
107
+ return () => document.removeEventListener('visibilitychange', onVisible)
108
+ }, [reconnectNow])
39
109
  // Host server-bridged tool calls (provider-engine sessions) in this tab, on the
40
110
  // SAME handle the panel attached with — the bridge asks the first attached
41
111
  // client. Free for Claude sessions: the guest loads lazily on the first call,
42
112
  // which for them never comes.
43
113
  useToolCallHost(handle)
114
+ const capabilities = state.capabilities
44
115
  const busy = state.status === 'running' || state.status === 'awaiting_approval'
45
116
  const ended = state.status === 'failed' || state.status === 'closed'
117
+ const attachments = useAttachments(client, sessionId, {
118
+ capabilities,
119
+ engine: state.engine,
120
+ })
121
+ // Rooted at the session's cwd, which arrives with the snapshot — so `@` is
122
+ // inert for the moment before it does, and stays inert on a gateway that
123
+ // serves no host files.
124
+ const hostFiles = useHostFileSearch(client, state.cwd)
125
+ const windows = useMemo(() => rateLimitWindows(state), [state])
126
+ // Reads a picture the engine left on the host (codex's `image_gen` reports a
127
+ // path, never bytes). Stable and memoized per path: transcript rows re-render
128
+ // on every delta, and a fresh function would re-fetch each time.
129
+ const hostImage = useHostImage(client, sessionId, state.producedFiles)
130
+ const composerRef = useRef<ComposerHandle>(null)
46
131
 
47
132
  // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
48
133
  // even though the CLI's command list doesn't include it.
@@ -55,43 +140,119 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
55
140
  ]
56
141
  }, [state.commands])
57
142
 
58
- // "/model <id>" switches the model directly instead of going to the CLI.
59
- const handleSend = (text: string) => {
60
- const modelCommand = /^\/model\s+(\S+)$/.exec(text)
61
- if (modelCommand) {
62
- setModel(modelCommand[1])
63
- return
143
+ // Two things are answered here rather than sent, because sending them would
144
+ // spend a turn on a model reading the words back.
145
+ const handleSend = (text: string, attachmentIds: string[]) => {
146
+ if (attachmentIds.length === 0) {
147
+ // "/model <id>" switches the model directly instead of going to the CLI.
148
+ const modelCommand = /^\/model\s+(\S+)$/.exec(text)
149
+ if (modelCommand) {
150
+ setModel(modelCommand[1])
151
+ return
152
+ }
153
+ // The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
154
+ // the capability exists: elsewhere it is ordinary message text, like any
155
+ // other slash command on an engine without them.
156
+ if (capabilities.mcpStatus && text.trim() === '/mcp') {
157
+ setPanel('mcp')
158
+ return
159
+ }
64
160
  }
65
- send(text)
161
+ send(text, attachmentIds)
66
162
  }
67
163
 
164
+ // Everything the panel can open, in one place — and each one is also
165
+ // reachable by clicking the thing on the bar that summarises it. Entries the
166
+ // capability record forswears are absent, not present-and-empty.
167
+ //
168
+ // Built once and placed once: either the embedder's header takes it (see the
169
+ // `header` render-prop) or the status bar does. Never both — two `⋯` menus on
170
+ // one screen is worse than either position.
171
+ const actionsMenu = (
172
+ <Menu>
173
+ <MenuTrigger
174
+ render={
175
+ <Button variant='ghost' size='icon-sm' aria-label='Session actions'>
176
+ <MoreHorizontal className='size-4' />
177
+ </Button>
178
+ }
179
+ />
180
+ <MenuContent>
181
+ {capabilities.contextUsage ? (
182
+ <MenuItem onClick={() => setPanel('context')}>
183
+ <ChartPie className='size-3.5 text-fg-3' /> Context
184
+ </MenuItem>
185
+ ) : null}
186
+ {capabilities.rateLimits ? (
187
+ <MenuItem onClick={() => setPanel('usage')}>
188
+ <Gauge className='size-3.5 text-fg-3' /> Usage
189
+ </MenuItem>
190
+ ) : null}
191
+ <MenuItem onClick={() => setPanel('info')}>
192
+ <Info className='size-3.5 text-fg-3' /> Session info
193
+ </MenuItem>
194
+ {capabilities.mcpStatus ? (
195
+ <MenuItem onClick={() => setPanel('mcp')}>
196
+ <Plug className='size-3.5 text-fg-3' /> MCP servers
197
+ </MenuItem>
198
+ ) : null}
199
+ {/* On the capability alone, like MCP's entry. Codex answers
200
+ `skills/list` only over a live child, so before the first turn there
201
+ is no list yet — but hiding the entry until then made the dialog's
202
+ own explanation of that unreachable, which read as the feature being
203
+ missing. The empty state says it instead. */}
204
+ {capabilities.skillsList ? (
205
+ <MenuItem onClick={() => setPanel('skills')}>
206
+ <Sparkles className='size-3.5 text-fg-3' /> Skills
207
+ </MenuItem>
208
+ ) : null}
209
+ {hostFiles.available ? (
210
+ <MenuItem onClick={() => setPanel('files')}>
211
+ <FolderTree className='size-3.5 text-fg-3' /> Project files
212
+ </MenuItem>
213
+ ) : null}
214
+ </MenuContent>
215
+ </Menu>
216
+ )
217
+
218
+ // A function header claims the menu; anything else leaves it on the status bar.
219
+ const headerTakesActions = typeof header === 'function'
220
+
68
221
  return (
69
222
  <div
70
223
  data-slot='session-panel'
71
224
  className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
72
- {header}
73
- <StatusBar state={state} connected={connected} />
225
+ {headerTakesActions ? header({ actions: actionsMenu }) : header}
226
+ <StatusBar
227
+ state={state}
228
+ connection={connection}
229
+ onOpenStatus={() => setPanel('info')}
230
+ onOpenContext={() => setPanel('context')}
231
+ onOpenUsage={() => setPanel('usage')}
232
+ actions={headerTakesActions ? undefined : actionsMenu}
233
+ />
234
+ {protocolMismatch !== undefined ? (
235
+ <Notice level='warning'>
236
+ Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
237
+ events may not render.
238
+ </Notice>
239
+ ) : null}
74
240
  {protocolError ? (
75
- <div className='px-3 pt-2'>
76
- <div
77
- role='alert'
78
- className='mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-body-sm text-danger'>
79
- <span className='min-w-0 flex-1 break-words'>{protocolError}</span>
80
- <button
81
- type='button'
82
- onClick={() => setProtocolError(undefined)}
83
- aria-label='Dismiss error'
84
- className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
85
-
86
- </button>
87
- </div>
88
- </div>
241
+ <Notice level='error' onDismiss={() => setProtocolError(undefined)}>
242
+ {protocolError}
243
+ </Notice>
89
244
  ) : null}
90
245
  <Transcript
91
246
  state={state}
92
247
  fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
248
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
249
+ canBrowseFiles={hostFiles.available}
250
+ hostImage={hostImage}
93
251
  />
94
- {state.pendingApprovals.length > 0 ? (
252
+ {/* An engine with no approval channel never raises these, but a stale
253
+ pending request from a replayed log would still render — the record is
254
+ the authority on whether an approval UI means anything here. */}
255
+ {capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
95
256
  <div className='px-3 pb-2'>
96
257
  <div className='mx-auto flex w-full max-w-3xl flex-col gap-2'>
97
258
  {state.pendingApprovals.map((request) =>
@@ -101,7 +262,7 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
101
262
  key={request.id}
102
263
  request={request}
103
264
  onAnswer={approve}
104
- onDismiss={deny}
265
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
105
266
  />
106
267
  ) : (
107
268
  <PermissionPrompt
@@ -116,17 +277,28 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
116
277
  </div>
117
278
  ) : null}
118
279
  <Composer
280
+ ref={composerRef}
119
281
  onSend={handleSend}
120
282
  onInterrupt={interrupt}
121
283
  busy={busy}
122
284
  disabled={ended || !sessionId}
123
- commands={commands}
285
+ commands={capabilities.slashCommands ? commands : undefined}
286
+ skills={capabilities.skillsList ? state.skills : undefined}
287
+ attachments={attachments}
288
+ onSearchFiles={
289
+ hostFiles.available
290
+ ? (query, options) => hostFiles.search(query, { ...options, limit: 8 })
291
+ : undefined
292
+ }
124
293
  toolbar={
125
294
  <>
126
- {state.models?.length ? (
295
+ {/* Codex reports no `capabilities` event, so its models arrive from
296
+ the profile catalog instead — without that fallback its picker
297
+ would be permanently empty and the session unswitchable. */}
298
+ {models.length ? (
127
299
  <ModelSelect
128
- models={state.models}
129
- model={state.model}
300
+ models={models}
301
+ model={effectiveModel}
130
302
  onModelChange={setModel}
131
303
  disabled={ended}
132
304
  />
@@ -135,15 +307,183 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
135
307
  <PermissionModeSelect
136
308
  mode={state.permissionMode}
137
309
  onModeChange={setPermissionMode}
138
- // A provider session rejects the CLI-only modes with a
139
- // protocol_error — don't offer what can only fail.
140
- modes={state.engine === 'provider' ? PROVIDER_PERMISSION_MODES : undefined}
310
+ // Only what this engine implements — the rest would come back as
311
+ // a protocol_error.
312
+ modes={capabilities.permissionModes}
313
+ canBypass={state.session?.canBypassPermissions}
141
314
  disabled={ended}
142
315
  />
143
316
  ) : null}
144
317
  </>
145
318
  }
146
319
  />
320
+
321
+ <SessionInfoDialog
322
+ state={state}
323
+ client={client}
324
+ sessionId={sessionId}
325
+ open={panel === 'info'}
326
+ onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
327
+ />
328
+ <ContextDialog
329
+ usage={state.contextUsage}
330
+ open={panel === 'context'}
331
+ onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
332
+ />
333
+ <UsageDialog
334
+ rateLimits={windows}
335
+ subscriptionType={state.subscriptionType}
336
+ engine={state.engine ?? 'claude'}
337
+ totalCostUsd={state.totalCostUsd}
338
+ updatedAt={state.rateLimitsUpdatedAt}
339
+ open={panel === 'usage'}
340
+ onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
341
+ />
342
+ <McpDialog
343
+ client={client}
344
+ sessionId={sessionId}
345
+ canManageServers={capabilities.mcpServerActions}
346
+ open={panel === 'mcp'}
347
+ onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
348
+ />
349
+ <SkillsDialog
350
+ skills={state.skills}
351
+ open={panel === 'skills'}
352
+ onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
353
+ // Drafts into the composer; the operator sends it. There is no engine
354
+ // call that runs a skill, so there is nothing else this button could do.
355
+ onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
356
+ />
357
+ <HostFilesDialog
358
+ client={client}
359
+ cwd={state.cwd}
360
+ open={panel === 'files'}
361
+ onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
362
+ />
363
+ </div>
364
+ )
365
+ }
366
+
367
+ /**
368
+ * Turns a host path a tool card is holding into something an `<img>` can show.
369
+ *
370
+ * Two sources, tried in that order and for a reason:
371
+ *
372
+ * 1. **The session's produced files.** If this session's own runner announced
373
+ * writing that path (`file_produced`), the gateway will serve it from
374
+ * `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
375
+ * cap to raise. This is the path codex's generated images take, and it is
376
+ * why they now render out of the box.
377
+ * 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
378
+ * at, an image already in the tree — where the operator's declared roots are
379
+ * the right gate and the answer is legitimately "no" outside them.
380
+ *
381
+ * The cache is what makes this usable from a transcript row: rows re-render on
382
+ * every streamed delta, and an uncached resolver would re-fetch the picture each
383
+ * time. A refusal is cached too, so it costs one request rather than one per
384
+ * render.
385
+ *
386
+ * Keyed by `fileId`-or-path so that a path which becomes produced *after* a
387
+ * failed `/fs/read` is retried under a different key rather than staying cached
388
+ * as a miss.
389
+ */
390
+ function useHostImage(
391
+ client: WorkerDeckClient,
392
+ sessionId: string | undefined,
393
+ producedFiles: Record<string, ProducedFileRef> | undefined,
394
+ ): (path: string) => Promise<string | undefined> {
395
+ const cache = useRef(new Map<string, Promise<string | undefined>>())
396
+ // Object URLs pin their blob until revoked, so a long session that generated
397
+ // a dozen images would hold a dozen megabytes past unmount.
398
+ const objectUrls = useRef<string[]>([])
399
+ useEffect(
400
+ () => () => {
401
+ for (const url of objectUrls.current) URL.revokeObjectURL(url)
402
+ objectUrls.current = []
403
+ },
404
+ [],
405
+ )
406
+ return useCallback(
407
+ (path: string) => {
408
+ const produced = producedFiles?.[path]
409
+ const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
410
+ const hit = cache.current.get(key)
411
+ if (hit) return hit
412
+ const pending =
413
+ produced && sessionId
414
+ ? // Fetched rather than pointed at: the panel may be talking to a
415
+ // header-authenticated gateway, where a bare URL in an `<img src>`
416
+ // carries no credential.
417
+ client
418
+ .readProducedFile(sessionId, produced.fileId)
419
+ .then((blob) => {
420
+ if (blob.size === 0) return undefined
421
+ const url = URL.createObjectURL(blob)
422
+ objectUrls.current.push(url)
423
+ return url
424
+ })
425
+ .catch(() => undefined)
426
+ : client
427
+ .readHostFile(path)
428
+ .then((file) => {
429
+ if (file.encoding !== 'base64') return undefined
430
+ // The route reports bytes and an encoding but not a media type;
431
+ // the extension is what a browser needs to decode it.
432
+ const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
433
+ const mediaType = IMAGE_MEDIA_TYPES[extension]
434
+ return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
435
+ })
436
+ .catch(() => undefined)
437
+ cache.current.set(key, pending)
438
+ return pending
439
+ },
440
+ [client, sessionId, producedFiles],
441
+ )
442
+ }
443
+
444
+ /** Extensions worth rendering inline, and what to call them. Anything else is
445
+ * left to the card's path text — guessing a media type is how an HTML file ends
446
+ * up in an `<img>`. */
447
+ const IMAGE_MEDIA_TYPES: Record<string, string> = {
448
+ png: 'image/png',
449
+ jpg: 'image/jpeg',
450
+ jpeg: 'image/jpeg',
451
+ gif: 'image/gif',
452
+ webp: 'image/webp',
453
+ }
454
+
455
+ /** A dismissible advisory strip above the transcript. */
456
+ function Notice({
457
+ level,
458
+ onDismiss,
459
+ children,
460
+ }: {
461
+ level: 'warning' | 'error'
462
+ onDismiss?: () => void
463
+ children: ReactNode
464
+ }) {
465
+ return (
466
+ <div className='px-3 pt-2'>
467
+ <div
468
+ role='alert'
469
+ className={cn(
470
+ 'mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
471
+ level === 'error'
472
+ ? 'border-danger/40 bg-danger-bg text-danger'
473
+ : 'border-warning/40 bg-warning-bg text-warning',
474
+ )}>
475
+ <TriangleAlert className='mt-0.5 size-3.5 shrink-0' />
476
+ <span className='min-w-0 flex-1 break-words'>{children}</span>
477
+ {onDismiss ? (
478
+ <button
479
+ type='button'
480
+ onClick={onDismiss}
481
+ aria-label='Dismiss'
482
+ className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
483
+ <X className='size-3.5' />
484
+ </button>
485
+ ) : null}
486
+ </div>
147
487
  </div>
148
488
  )
149
489
  }