@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.
- package/README.md +19 -0
- package/build/{SessionPanel-NQ8ksCfj.mjs → SessionPanel-DI1NO4l8.mjs} +223 -163
- package/build/SessionPanel-DI1NO4l8.mjs.map +1 -0
- package/build/{SessionPanel-Dy9lQrOV.d.mts → SessionPanel-J2U8v88q.d.mts} +86 -8
- package/build/index.d.mts +118 -7
- package/build/index.mjs +355 -169
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +13 -1
- package/build/workspace.mjs +7 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +112 -37
- package/src/components/agent/EngineIcon.tsx +97 -0
- package/src/components/agent/Loader.tsx +4 -1
- package/src/components/agent/Message.tsx +12 -5
- package/src/components/agent/QuestionPrompt.tsx +1 -1
- package/src/components/agent/SessionBrowser.tsx +266 -138
- package/src/components/agent/SessionPanel.tsx +149 -48
- package/src/components/agent/SessionWorkspace.tsx +17 -0
- package/src/components/agent/StatusBar.tsx +58 -12
- package/src/components/agent/Transcript.tsx +2 -3
- package/src/components/agent/line-prompt.tsx +2 -2
- package/src/components/agent/transcript-variant.tsx +14 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Menu.tsx +1 -1
- package/src/components/ui/Select.tsx +1 -1
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/styles/theme.css +77 -15
- package/build/SessionPanel-NQ8ksCfj.mjs.map +0 -1
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import {
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
<
|
|
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='
|
|
118
|
-
value={config.
|
|
119
|
-
options={
|
|
120
|
-
onChange={(
|
|
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
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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-
|
|
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
|
|
250
|
-
active
|
|
324
|
+
'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
|
|
325
|
+
rowShapeClass(active === true),
|
|
251
326
|
)}>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
className='
|
|
289
|
-
|
|
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-
|
|
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
|
-
?
|
|
508
|
+
? 'All'
|
|
381
509
|
: value.length === 1
|
|
382
|
-
? (options.find((o) => o.value === value[0])?.label ??
|
|
383
|
-
: `${
|
|
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-
|
|
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>
|