@workerdeck/ui 0.12.0 → 0.15.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 (32) hide show
  1. package/README.md +19 -0
  2. package/build/{SessionPanel-NQ8ksCfj.mjs → SessionPanel-DI1NO4l8.mjs} +223 -163
  3. package/build/SessionPanel-DI1NO4l8.mjs.map +1 -0
  4. package/build/{SessionPanel-Dy9lQrOV.d.mts → SessionPanel-J2U8v88q.d.mts} +86 -8
  5. package/build/index.d.mts +118 -7
  6. package/build/index.mjs +355 -169
  7. package/build/index.mjs.map +1 -1
  8. package/build/workspace.d.mts +13 -1
  9. package/build/workspace.mjs +7 -2
  10. package/build/workspace.mjs.map +1 -1
  11. package/package.json +4 -4
  12. package/src/components/agent/Composer.tsx +112 -37
  13. package/src/components/agent/EngineIcon.tsx +97 -0
  14. package/src/components/agent/Loader.tsx +4 -1
  15. package/src/components/agent/Message.tsx +12 -5
  16. package/src/components/agent/QuestionPrompt.tsx +1 -1
  17. package/src/components/agent/SessionBrowser.tsx +266 -138
  18. package/src/components/agent/SessionPanel.tsx +149 -48
  19. package/src/components/agent/SessionWorkspace.tsx +17 -0
  20. package/src/components/agent/StatusBar.tsx +58 -12
  21. package/src/components/agent/Transcript.tsx +2 -3
  22. package/src/components/agent/line-prompt.tsx +2 -2
  23. package/src/components/agent/transcript-variant.tsx +14 -0
  24. package/src/components/ui/Badge.tsx +6 -1
  25. package/src/components/ui/Empty.tsx +56 -0
  26. package/src/components/ui/Menu.tsx +1 -1
  27. package/src/components/ui/Select.tsx +1 -1
  28. package/src/components/ui/Splitter.tsx +14 -0
  29. package/src/components/ui/Tooltip.tsx +1 -1
  30. package/src/index.ts +11 -1
  31. package/src/styles/theme.css +77 -15
  32. package/build/SessionPanel-NQ8ksCfj.mjs.map +0 -1
@@ -1,5 +1,17 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
- import { Pencil, Search, Trash2, X } from 'lucide-react'
2
+ import {
3
+ BellRing,
4
+ CircleAlert,
5
+ CircleSlash,
6
+ Layers,
7
+ Moon,
8
+ PauseCircle,
9
+ Pencil,
10
+ Search,
11
+ SearchX,
12
+ Trash2,
13
+ X,
14
+ } from 'lucide-react'
3
15
  import {
4
16
  STATE_LABELS,
5
17
  STATE_ORDER,
@@ -19,13 +31,13 @@ import type {
19
31
  ViewConfig,
20
32
  WorkspaceScope,
21
33
  } from '@workerdeck/protocol'
22
- import { Badge } from '../ui/Badge.tsx'
23
34
  import { Button } from '../ui/Button.tsx'
35
+ import { Empty } from '../ui/Empty.tsx'
24
36
  import { Input } from '../ui/Input.tsx'
25
37
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
38
+ import { Spinner } from '../ui/Spinner.tsx'
26
39
  import { cn } from '../../lib/utils.ts'
27
- import { formatCost, formatRelativeTime } from '../../lib/format.ts'
28
- import { STATUS_META } from './status.ts'
40
+ import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
29
41
 
30
42
  /**
31
43
  * A sessions list with the affordances a list of thirty needs: search, facets,
@@ -64,9 +76,44 @@ export interface SessionBrowserProps {
64
76
  onRename?: (row: SessionRow, title: string) => void
65
77
  /** Rendered when nothing at all exists (as opposed to nothing matching). */
66
78
  emptyState?: React.ReactNode
79
+ /**
80
+ * Whether the search + facet bar is shown. Defaults to `true` — a list that
81
+ * *is* the screen shows its controls.
82
+ *
83
+ * A host with somewhere better to put the toggle (a view title bar) passes
84
+ * `false` and owns the boolean itself, the way the VS Code extension does: the
85
+ * key lives where the commands do. Two rules come with it, and they are why
86
+ * this hides only the bar and nothing else — **closing the bar never clears
87
+ * the filters**, and the subset line below it renders either way, so a list
88
+ * filtered by a control you can't currently see still says so.
89
+ */
90
+ showControls?: boolean
67
91
  className?: string
68
92
  }
69
93
 
94
+ /**
95
+ * How a list row is drawn, in one place, so `SidebarRow` in `web` matches this
96
+ * exactly rather than approximating it — the dashboard's other three sidebars
97
+ * are that component, and a sessions list that hovered differently from the
98
+ * gateways list beside it would read as a different product.
99
+ *
100
+ * Two rules are load-bearing:
101
+ *
102
+ * - **Fill means hover, and only hover.** It stays on the row whether or not
103
+ * the row is selected, because a selected row still has to answer the
104
+ * pointer. Selection gets the gutter instead.
105
+ * - **`ml-0` on the selected row is not cosmetic.** It hands the accent border
106
+ * the 4px the margin was holding, so the text does not shift sideways as a
107
+ * row becomes the selected one. The squared left corners are what let the bar
108
+ * sit flush against the sidebar edge.
109
+ */
110
+ export function rowShapeClass(active: boolean): string {
111
+ return cn(
112
+ 'px-2 py-1.5 hover:bg-row-hover',
113
+ active ? 'mr-1 ml-0 rounded-r-md border-l-4 border-l-accent' : 'mx-1 rounded-md',
114
+ )
115
+ }
116
+
70
117
  export function SessionBrowser({
71
118
  rows,
72
119
  config,
@@ -77,6 +124,7 @@ export function SessionBrowser({
77
124
  onDelete,
78
125
  onRename,
79
126
  emptyState,
127
+ showControls = true,
80
128
  className,
81
129
  }: SessionBrowserProps) {
82
130
  const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
@@ -95,8 +143,13 @@ export function SessionBrowser({
95
143
 
96
144
  return (
97
145
  <div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
98
- <div className='flex flex-wrap items-center gap-2'>
99
- <div className='relative min-w-48 flex-1'>
146
+ {/* One control per row, label left, input right — the shape VS Code uses
147
+ for its own filter surfaces. A wrapping row of pill-shaped selects
148
+ reflows into an unpredictable number of lines as facets appear and
149
+ disappear; a column of labelled rows is the same height every time and
150
+ reads at sidebar width, which is the only width this has. */}
151
+ <div className={cn('flex flex-col gap-1.5 px-2', !showControls && 'hidden')}>
152
+ <div className='relative'>
100
153
  <Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
101
154
  <Input
102
155
  value={config.search}
@@ -106,54 +159,64 @@ export function SessionBrowser({
106
159
  className='pl-8'
107
160
  />
108
161
  </div>
109
- <FacetSelect
110
- label='State'
111
- value={config.states}
112
- options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
113
- onChange={(states) => set({ states: states as SessionState[] })}
114
- />
115
- {adapters.length > 1 ? (
162
+ <FilterRow label='State'>
116
163
  <FacetSelect
117
- label='Engine'
118
- value={config.adapters}
119
- options={adapters.map((a) => ({ value: a, label: a }))}
120
- onChange={(adapters) => set({ adapters })}
164
+ label='State'
165
+ value={config.states}
166
+ options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
167
+ onChange={(states) => set({ states: states as SessionState[] })}
121
168
  />
169
+ </FilterRow>
170
+ {adapters.length > 1 ? (
171
+ <FilterRow label='Engine'>
172
+ <FacetSelect
173
+ label='Engine'
174
+ value={config.adapters}
175
+ options={adapters.map((a) => ({ value: a, label: a }))}
176
+ onChange={(adapters) => set({ adapters })}
177
+ />
178
+ </FilterRow>
122
179
  ) : null}
123
180
  {gateways.length > 1 ? (
124
- <FacetSelect
125
- label='Gateway'
126
- value={config.gateways}
127
- options={gateways.map((g) => ({ value: g.id, label: g.name }))}
128
- onChange={(gateways) => set({ gateways })}
129
- />
181
+ <FilterRow label='Gateway'>
182
+ <FacetSelect
183
+ label='Gateway'
184
+ value={config.gateways}
185
+ options={gateways.map((g) => ({ value: g.id, label: g.name }))}
186
+ onChange={(gateways) => set({ gateways })}
187
+ />
188
+ </FilterRow>
130
189
  ) : null}
131
- <OneOfSelect
132
- label='Group'
133
- value={config.groupBy}
134
- options={[
135
- { value: 'none', label: 'No grouping' },
136
- { value: 'state', label: 'By state' },
137
- { value: 'adapter', label: 'By engine' },
138
- ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
139
- ]}
140
- onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
141
- />
142
- <OneOfSelect
143
- label='Sort'
144
- value={config.sortBy}
145
- options={[
146
- { value: 'recent', label: 'Recent' },
147
- { value: 'name', label: 'Name' },
148
- { value: 'state', label: 'State' },
149
- ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
150
- ]}
151
- onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
152
- />
190
+ <FilterRow label='Group'>
191
+ <OneOfSelect
192
+ label='Group'
193
+ value={config.groupBy}
194
+ options={[
195
+ { value: 'none', label: 'No grouping' },
196
+ { value: 'state', label: 'By state' },
197
+ { value: 'adapter', label: 'By engine' },
198
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
199
+ ]}
200
+ onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
201
+ />
202
+ </FilterRow>
203
+ <FilterRow label='Sort'>
204
+ <OneOfSelect
205
+ label='Sort'
206
+ value={config.sortBy}
207
+ options={[
208
+ { value: 'recent', label: 'Recent' },
209
+ { value: 'name', label: 'Name' },
210
+ { value: 'state', label: 'State' },
211
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
212
+ ]}
213
+ onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
214
+ />
215
+ </FilterRow>
153
216
  </div>
154
217
 
155
218
  {subset ? (
156
- <div className='flex items-center gap-2 text-label text-fg-4'>
219
+ <div className='flex items-center gap-2 px-3 text-label text-fg-4'>
157
220
  <span>
158
221
  {subset.shown} of {subset.total}
159
222
  {subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
@@ -168,31 +231,29 @@ export function SessionBrowser({
168
231
  ) : null}
169
232
 
170
233
  {rows.length === 0 ? (
171
- (emptyState ?? <Empty>No sessions yet.</Empty>)
234
+ (emptyState ?? <Empty icon={<Layers />} title='No sessions yet' />)
172
235
  ) : visible.length === 0 ? (
173
236
  // Two different dead ends, two different ways out. "Nothing matches" is
174
- // a filter someone set; anything else is the state of the world.
175
- <Empty>
176
- {hasFacetFilter(config) ? (
177
- <>
178
- No sessions match.{' '}
179
- <button
180
- type='button'
181
- className='underline underline-offset-2 hover:text-fg-1'
182
- onClick={() => onConfigChange(clearFilters(config))}>
183
- Clear filters
184
- </button>
185
- </>
186
- ) : (
187
- 'No sessions here.'
188
- )}
189
- </Empty>
237
+ // a filter someone set; anything else is the state of the world — and
238
+ // only the first has a button, because an action that does nothing is
239
+ // worse than none.
240
+ hasFacetFilter(config) ? (
241
+ <Empty
242
+ icon={<SearchX />}
243
+ title='No matches'
244
+ description='No session matches the current search and filters.'
245
+ action='Clear filters'
246
+ onAction={() => onConfigChange(clearFilters(config))}
247
+ />
248
+ ) : (
249
+ <Empty icon={<Layers />} title='Nothing here' description='No session to show.' />
250
+ )
190
251
  ) : (
191
252
  <div className='flex flex-col gap-4'>
192
253
  {groups.map((group) => (
193
254
  <div key={group.key} className='flex flex-col gap-1'>
194
255
  {config.groupBy !== 'none' && group.label ? (
195
- <div className='flex items-baseline gap-2 px-2.5 text-label font-medium text-fg-4'>
256
+ <div className='flex items-baseline gap-2 px-3 text-label font-medium text-fg-4'>
196
257
  <span className='uppercase tracking-wide'>{group.label}</span>
197
258
  <span className='text-fg-4/70'>{group.rows.length}</span>
198
259
  </div>
@@ -216,10 +277,6 @@ export function SessionBrowser({
216
277
  )
217
278
  }
218
279
 
219
- function Empty({ children }: { children: React.ReactNode }) {
220
- return <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{children}</div>
221
- }
222
-
223
280
  interface SessionRowItemProps {
224
281
  row: SessionRow
225
282
  active?: boolean
@@ -238,87 +295,141 @@ function SessionRowItem({
238
295
  onRename,
239
296
  }: SessionRowItemProps) {
240
297
  const { info } = row
241
- const meta = STATUS_META[info.status]
242
298
  const [editing, setEditing] = useState(false)
243
299
 
300
+ // What it is and what it has spent, in one line — the same set the extension
301
+ // shows, joined the same way, so the two lists read as one product.
302
+ const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
303
+ const details = [
304
+ showGateway ? row.hostName : undefined,
305
+ friendlyModel(info.model),
306
+ folder,
307
+ info.profile ? `@${info.profile}` : undefined,
308
+ formatCost(info.totalCostUsd),
309
+ ].filter(Boolean)
310
+
244
311
  return (
245
312
  <div
246
313
  data-slot='session-row'
247
314
  data-active={active || undefined}
315
+ // Selection lives on the whole row, not on the two text buttons inside
316
+ // it: the age, the unread badge and the state glyph sit outside them, and
317
+ // a row where a third of the surface silently does nothing is a row that
318
+ // feels broken. The buttons stay — they are what makes the row reachable
319
+ // by keyboard — but their activation now reaches this handler by
320
+ // bubbling, so there is one code path and no double-fire. Anything that
321
+ // is its own action (rename, close, the name editor) stops the event.
322
+ onClick={() => !editing && onSelect?.(row)}
248
323
  className={cn(
249
- 'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
250
- active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
324
+ 'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
325
+ rowShapeClass(active === true),
251
326
  )}>
252
- <div className='min-w-0 flex-1'>
253
- <div className='flex items-center gap-2'>
254
- {/* The editor replaces the link rather than sitting inside it — an
255
- input nested in a button is invalid, and disabling the button to
256
- protect the edit disables the field with it. */}
257
- {editing && onRename ? (
258
- <NameEditor
259
- initial={info.title ?? ''}
260
- onCommit={(title) => {
261
- setEditing(false)
262
- onRename(row, title)
263
- }}
264
- onCancel={() => setEditing(false)}
265
- />
266
- ) : (
267
- <button
268
- type='button'
269
- onClick={() => onSelect?.(row)}
270
- className='min-w-0 truncate text-left text-body-sm font-medium text-fg-1 outline-none'>
271
- {sessionLabel(info)}
272
- </button>
273
- )}
274
- <Badge variant={meta.variant} dot className='shrink-0'>
275
- {meta.label}
276
- </Badge>
277
- {row.unseen > 0 ? (
278
- // Transcript rows since this session was last on screen — the same
279
- // unit the VS Code badge counts, because turns undercount badly.
280
- <Badge variant='accent' className='shrink-0' title={`${row.unseen} new`}>
281
- {row.unseen}
282
- </Badge>
283
- ) : null}
284
- </div>
327
+ {/* Line one: what you scan the list by on the left, how it is doing on the
328
+ right, state last. */}
329
+ <div className='flex items-center gap-1.5'>
330
+ {/* The editor replaces the link rather than sitting inside it — an
331
+ input nested in a button is invalid, and disabling the button to
332
+ protect the edit disables the field with it. */}
333
+ {editing && onRename ? (
334
+ <NameEditor
335
+ initial={info.title ?? ''}
336
+ onCommit={(title) => {
337
+ setEditing(false)
338
+ onRename(row, title)
339
+ }}
340
+ onCancel={() => setEditing(false)}
341
+ />
342
+ ) : (
343
+ <button
344
+ type='button'
345
+ className={cn(
346
+ 'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
347
+ active ? 'font-medium text-fg-1' : 'text-fg-2',
348
+ )}>
349
+ {sessionLabel(info)}
350
+ </button>
351
+ )}
352
+ {row.unseen > 0 ? (
353
+ // Transcript rows since this session was last on screen — the same
354
+ // unit the VS Code badge counts, because turns undercount badly.
355
+ <span
356
+ title={`${row.unseen} new`}
357
+ className='shrink-0 rounded-full bg-accent px-1.5 text-label text-accent-fg'>
358
+ {row.unseen}
359
+ </span>
360
+ ) : null}
361
+ <span className='shrink-0 text-label text-fg-4'>
362
+ {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
363
+ </span>
364
+ <SessionStatusIcon row={row} />
365
+ </div>
366
+
367
+ {/* Line two: what it is, with the actions at the far right — away from the
368
+ state icon, and away from the title you are actually reading. */}
369
+ <div className='flex items-center gap-1 text-label text-fg-4'>
285
370
  <button
286
371
  type='button'
287
- onClick={() => !editing && onSelect?.(row)}
288
- className='mt-0.5 flex w-full items-center gap-2 text-left font-mono text-label text-fg-4 outline-none'>
289
- <span className='truncate'>{info.cwd}</span>
290
- {showGateway ? <span className='shrink-0'>{row.hostName}</span> : null}
291
- {info.profile ? <span className='shrink-0'>@{info.profile}</span> : null}
292
- <span className='shrink-0'>{formatCost(info.totalCostUsd)}</span>
293
- <span className='shrink-0'>
294
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
295
- </span>
372
+ tabIndex={-1}
373
+ className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
374
+ {details.join(' · ')}
296
375
  </button>
376
+ {onRename && !editing ? (
377
+ <Button
378
+ variant='ghost'
379
+ size='icon-sm'
380
+ aria-label='Rename session'
381
+ className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
382
+ onClick={(e) => {
383
+ e.stopPropagation()
384
+ setEditing(true)
385
+ }}>
386
+ <Pencil className='size-3 text-fg-3' />
387
+ </Button>
388
+ ) : null}
389
+ {onDelete ? (
390
+ <Button
391
+ variant='ghost'
392
+ size='icon-sm'
393
+ aria-label='Close session'
394
+ className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
395
+ onClick={(e) => {
396
+ e.stopPropagation()
397
+ onDelete(row)
398
+ }}>
399
+ <Trash2 className='size-3 text-fg-3' />
400
+ </Button>
401
+ ) : null}
297
402
  </div>
298
- {onRename && !editing ? (
299
- <Button
300
- variant='ghost'
301
- size='icon-sm'
302
- aria-label='Rename session'
303
- className='opacity-0 transition-opacity group-hover:opacity-100'
304
- onClick={() => setEditing(true)}>
305
- <Pencil className='size-3.5 text-fg-3' />
306
- </Button>
307
- ) : null}
308
- {onDelete ? (
309
- <Button
310
- variant='ghost'
311
- size='icon-sm'
312
- aria-label='Close session'
313
- className='opacity-0 transition-opacity group-hover:opacity-100'
314
- onClick={() => onDelete(row)}>
315
- <Trash2 className='size-3.5 text-fg-3' />
316
- </Button>
317
- ) : null}
318
403
  </div>
319
404
  )
320
405
  }
321
406
 
407
+ /**
408
+ * State as one glyph on the right edge — a ringing bell when it wants a human, a
409
+ * spinner while it works, a moon when it is only sleeping. Replaces the text
410
+ * badge: in a sidebar the word costs more room than it earns, and the states
411
+ * that matter are the two you can recognise without reading.
412
+ */
413
+ export function SessionStatusIcon({ row }: { row: SessionRow }) {
414
+ const { info } = row
415
+ if (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') {
416
+ return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
417
+ }
418
+ if (info.status === 'running' || info.status === 'starting') {
419
+ return <Spinner className='size-3.5 shrink-0 text-info' />
420
+ }
421
+ switch (info.status) {
422
+ case 'failed':
423
+ return <CircleAlert className='size-3.5 shrink-0 text-danger' />
424
+ case 'closed':
425
+ return <CircleSlash className='size-3.5 shrink-0 text-fg-4' />
426
+ case 'parked':
427
+ return <PauseCircle className='size-3.5 shrink-0 text-fg-3' />
428
+ default:
429
+ return <Moon className='size-3.5 shrink-0 text-fg-4' />
430
+ }
431
+ }
432
+
322
433
  /**
323
434
  * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
324
435
  * that is still inside this document: switching windows must not silently commit,
@@ -360,6 +471,22 @@ function NameEditor({
360
471
 
361
472
  /** A multi-select facet. Empty = no filter, which is why the trigger reads the
362
473
  * facet's name rather than "All": nothing is being excluded. */
474
+ /**
475
+ * One labelled control row. The label column is fixed so every control starts
476
+ * on the same x — the thing that makes a stack of them read as a form rather
477
+ * than as five unrelated widgets.
478
+ */
479
+ function FilterRow({ label, children }: { label: string; children: React.ReactNode }) {
480
+ return (
481
+ <div className='flex items-center gap-2'>
482
+ <span aria-hidden className='w-14 shrink-0 truncate text-label text-fg-3'>
483
+ {label}
484
+ </span>
485
+ <div className='min-w-0 flex-1'>{children}</div>
486
+ </div>
487
+ )
488
+ }
489
+
363
490
  function FacetSelect({
364
491
  label,
365
492
  value,
@@ -374,13 +501,14 @@ function FacetSelect({
374
501
  return (
375
502
  <div className='flex items-center gap-1'>
376
503
  <Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
377
- <SelectTrigger aria-label={label} className='min-w-28'>
504
+ <SelectTrigger aria-label={label} className='min-w-0 flex-1'>
378
505
  <SelectValue>
506
+ {/* "All" rather than the label, which the row already carries. */}
379
507
  {value.length === 0
380
- ? label
508
+ ? 'All'
381
509
  : value.length === 1
382
- ? (options.find((o) => o.value === value[0])?.label ?? label)
383
- : `${label} · ${value.length}`}
510
+ ? (options.find((o) => o.value === value[0])?.label ?? 'All')
511
+ : `${value.length} selected`}
384
512
  </SelectValue>
385
513
  </SelectTrigger>
386
514
  <SelectContent>
@@ -413,7 +541,7 @@ function OneOfSelect({
413
541
  }) {
414
542
  return (
415
543
  <Select value={value} onValueChange={(v) => onChange(v as string)}>
416
- <SelectTrigger aria-label={label} className='min-w-28'>
544
+ <SelectTrigger aria-label={label} className='w-full min-w-0'>
417
545
  <SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
418
546
  </SelectTrigger>
419
547
  <SelectContent>