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