@workerdeck/ui 0.12.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-NQ8ksCfj.mjs → SessionPanel-CKQa4i0Y.mjs} +113 -110
- package/build/SessionPanel-CKQa4i0Y.mjs.map +1 -0
- package/build/{SessionPanel-Dy9lQrOV.d.mts → SessionPanel-CZMA44NM.d.mts} +30 -1
- package/build/index.d.mts +66 -7
- package/build/index.mjs +283 -152
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +4 -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 +4 -1
- package/src/components/agent/Message.tsx +10 -6
- package/src/components/agent/QuestionPrompt.tsx +1 -1
- package/src/components/agent/SessionBrowser.tsx +235 -117
- package/src/components/agent/SessionPanel.tsx +61 -13
- package/src/components/agent/SessionWorkspace.tsx +10 -0
- package/src/components/agent/StatusBar.tsx +9 -2
- package/src/components/agent/Transcript.tsx +2 -3
- package/src/components/agent/line-prompt.tsx +2 -2
- 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/index.ts +9 -1
- package/src/styles/theme.css +45 -15
- package/build/SessionPanel-NQ8ksCfj.mjs.map +0 -1
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BellRing,
|
|
4
|
+
CircleAlert,
|
|
5
|
+
CircleSlash,
|
|
6
|
+
Moon,
|
|
7
|
+
PauseCircle,
|
|
8
|
+
Pencil,
|
|
9
|
+
Search,
|
|
10
|
+
Trash2,
|
|
11
|
+
X,
|
|
12
|
+
} from 'lucide-react'
|
|
3
13
|
import {
|
|
4
14
|
STATE_LABELS,
|
|
5
15
|
STATE_ORDER,
|
|
@@ -19,13 +29,12 @@ import type {
|
|
|
19
29
|
ViewConfig,
|
|
20
30
|
WorkspaceScope,
|
|
21
31
|
} from '@workerdeck/protocol'
|
|
22
|
-
import { Badge } from '../ui/Badge.tsx'
|
|
23
32
|
import { Button } from '../ui/Button.tsx'
|
|
24
33
|
import { Input } from '../ui/Input.tsx'
|
|
25
34
|
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
35
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
26
36
|
import { cn } from '../../lib/utils.ts'
|
|
27
|
-
import { formatCost, formatRelativeTime } from '../../lib/format.ts'
|
|
28
|
-
import { STATUS_META } from './status.ts'
|
|
37
|
+
import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
|
|
29
38
|
|
|
30
39
|
/**
|
|
31
40
|
* A sessions list with the affordances a list of thirty needs: search, facets,
|
|
@@ -64,9 +73,44 @@ export interface SessionBrowserProps {
|
|
|
64
73
|
onRename?: (row: SessionRow, title: string) => void
|
|
65
74
|
/** Rendered when nothing at all exists (as opposed to nothing matching). */
|
|
66
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
|
|
67
88
|
className?: string
|
|
68
89
|
}
|
|
69
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
|
+
|
|
70
114
|
export function SessionBrowser({
|
|
71
115
|
rows,
|
|
72
116
|
config,
|
|
@@ -77,6 +121,7 @@ export function SessionBrowser({
|
|
|
77
121
|
onDelete,
|
|
78
122
|
onRename,
|
|
79
123
|
emptyState,
|
|
124
|
+
showControls = true,
|
|
80
125
|
className,
|
|
81
126
|
}: SessionBrowserProps) {
|
|
82
127
|
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
@@ -95,8 +140,13 @@ export function SessionBrowser({
|
|
|
95
140
|
|
|
96
141
|
return (
|
|
97
142
|
<div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
|
|
98
|
-
|
|
99
|
-
|
|
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'>
|
|
100
150
|
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
101
151
|
<Input
|
|
102
152
|
value={config.search}
|
|
@@ -106,54 +156,64 @@ export function SessionBrowser({
|
|
|
106
156
|
className='pl-8'
|
|
107
157
|
/>
|
|
108
158
|
</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 ? (
|
|
159
|
+
<FilterRow label='State'>
|
|
116
160
|
<FacetSelect
|
|
117
|
-
label='
|
|
118
|
-
value={config.
|
|
119
|
-
options={
|
|
120
|
-
onChange={(
|
|
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[] })}
|
|
121
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>
|
|
122
176
|
) : null}
|
|
123
177
|
{gateways.length > 1 ? (
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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>
|
|
130
186
|
) : null}
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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>
|
|
153
213
|
</div>
|
|
154
214
|
|
|
155
215
|
{subset ? (
|
|
156
|
-
<div className='flex items-center gap-2 text-label text-fg-4'>
|
|
216
|
+
<div className='flex items-center gap-2 px-3 text-label text-fg-4'>
|
|
157
217
|
<span>
|
|
158
218
|
{subset.shown} of {subset.total}
|
|
159
219
|
{subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
|
|
@@ -192,7 +252,7 @@ export function SessionBrowser({
|
|
|
192
252
|
{groups.map((group) => (
|
|
193
253
|
<div key={group.key} className='flex flex-col gap-1'>
|
|
194
254
|
{config.groupBy !== 'none' && group.label ? (
|
|
195
|
-
<div className='flex items-baseline gap-2 px-
|
|
255
|
+
<div className='flex items-baseline gap-2 px-3 text-label font-medium text-fg-4'>
|
|
196
256
|
<span className='uppercase tracking-wide'>{group.label}</span>
|
|
197
257
|
<span className='text-fg-4/70'>{group.rows.length}</span>
|
|
198
258
|
</div>
|
|
@@ -217,7 +277,7 @@ export function SessionBrowser({
|
|
|
217
277
|
}
|
|
218
278
|
|
|
219
279
|
function Empty({ children }: { children: React.ReactNode }) {
|
|
220
|
-
return <div className='px-
|
|
280
|
+
return <div className='px-3 py-6 text-center text-body-sm text-fg-4'>{children}</div>
|
|
221
281
|
}
|
|
222
282
|
|
|
223
283
|
interface SessionRowItemProps {
|
|
@@ -238,87 +298,128 @@ function SessionRowItem({
|
|
|
238
298
|
onRename,
|
|
239
299
|
}: SessionRowItemProps) {
|
|
240
300
|
const { info } = row
|
|
241
|
-
const meta = STATUS_META[info.status]
|
|
242
301
|
const [editing, setEditing] = useState(false)
|
|
243
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
|
+
|
|
244
314
|
return (
|
|
245
315
|
<div
|
|
246
316
|
data-slot='session-row'
|
|
247
317
|
data-active={active || undefined}
|
|
248
318
|
className={cn(
|
|
249
|
-
'group flex
|
|
250
|
-
active
|
|
319
|
+
'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
|
|
320
|
+
rowShapeClass(active === true),
|
|
251
321
|
)}>
|
|
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
|
-
|
|
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'>
|
|
285
366
|
<button
|
|
286
367
|
type='button'
|
|
287
368
|
onClick={() => !editing && onSelect?.(row)}
|
|
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>
|
|
369
|
+
className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
|
|
370
|
+
{details.join(' · ')}
|
|
296
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}
|
|
297
392
|
</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
393
|
</div>
|
|
319
394
|
)
|
|
320
395
|
}
|
|
321
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
|
+
|
|
322
423
|
/**
|
|
323
424
|
* Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
|
|
324
425
|
* that is still inside this document: switching windows must not silently commit,
|
|
@@ -360,6 +461,22 @@ function NameEditor({
|
|
|
360
461
|
|
|
361
462
|
/** A multi-select facet. Empty = no filter, which is why the trigger reads the
|
|
362
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
|
+
|
|
363
480
|
function FacetSelect({
|
|
364
481
|
label,
|
|
365
482
|
value,
|
|
@@ -374,13 +491,14 @@ function FacetSelect({
|
|
|
374
491
|
return (
|
|
375
492
|
<div className='flex items-center gap-1'>
|
|
376
493
|
<Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
|
|
377
|
-
<SelectTrigger aria-label={label} className='min-w-
|
|
494
|
+
<SelectTrigger aria-label={label} className='min-w-0 flex-1'>
|
|
378
495
|
<SelectValue>
|
|
496
|
+
{/* "All" rather than the label, which the row already carries. */}
|
|
379
497
|
{value.length === 0
|
|
380
|
-
?
|
|
498
|
+
? 'All'
|
|
381
499
|
: value.length === 1
|
|
382
|
-
? (options.find((o) => o.value === value[0])?.label ??
|
|
383
|
-
: `${
|
|
500
|
+
? (options.find((o) => o.value === value[0])?.label ?? 'All')
|
|
501
|
+
: `${value.length} selected`}
|
|
384
502
|
</SelectValue>
|
|
385
503
|
</SelectTrigger>
|
|
386
504
|
<SelectContent>
|
|
@@ -413,7 +531,7 @@ function OneOfSelect({
|
|
|
413
531
|
}) {
|
|
414
532
|
return (
|
|
415
533
|
<Select value={value} onValueChange={(v) => onChange(v as string)}>
|
|
416
|
-
<SelectTrigger aria-label={label} className='min-w-
|
|
534
|
+
<SelectTrigger aria-label={label} className='w-full min-w-0'>
|
|
417
535
|
<SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
|
|
418
536
|
</SelectTrigger>
|
|
419
537
|
<SelectContent>
|
|
@@ -98,6 +98,17 @@ export interface SessionPanelProps {
|
|
|
98
98
|
* take the menu, or it has nowhere left to go.
|
|
99
99
|
*/
|
|
100
100
|
statusSurface?: 'internal' | 'external'
|
|
101
|
+
/**
|
|
102
|
+
* Which end of the panel the status bar sits at. Default `top`.
|
|
103
|
+
*
|
|
104
|
+
* `bottom` is the editor convention — VS Code's status bar runs along the
|
|
105
|
+
* foot of the window — and suits a host where the panel *is* the editor area
|
|
106
|
+
* and the chrome above it already belongs to the app. Placement only; the bar
|
|
107
|
+
* is the same bar, with the same `⋯` menu in its trailing slot, so this
|
|
108
|
+
* composes with {@link statusSurface} rather than competing with it (external
|
|
109
|
+
* still means "there isn't one").
|
|
110
|
+
*/
|
|
111
|
+
statusPlacement?: 'top' | 'bottom'
|
|
101
112
|
/** Where `panelSurface: 'external'` routes opens. Absent = the affordances
|
|
102
113
|
* (status-bar clicks, `/mcp`) become inert rather than half-working. */
|
|
103
114
|
onOpenPanel?: (panel: SessionSurfacePanel) => void
|
|
@@ -161,6 +172,22 @@ export interface SessionPanelProps {
|
|
|
161
172
|
* the number to remember through `SessionVitals.itemCount`.
|
|
162
173
|
*/
|
|
163
174
|
unseen?: { itemCount: number; since?: number }
|
|
175
|
+
/**
|
|
176
|
+
* A viewer, not a seat at the session: transcript, status bar and panels as
|
|
177
|
+
* usual, but no composer and no approval prompts.
|
|
178
|
+
*
|
|
179
|
+
* For a surface that is *about* a run rather than in it — the dashboard's job
|
|
180
|
+
* detail, where the session belongs to the queue and typing into it would be a
|
|
181
|
+
* second operator arriving mid-run. Deliberately not "disabled controls": a
|
|
182
|
+
* greyed-out composer says the session is busy, an absent one says this screen
|
|
183
|
+
* does not drive it. The attach is still live and read paths are untouched,
|
|
184
|
+
* so the transcript streams and the file tree browses.
|
|
185
|
+
*
|
|
186
|
+
* It does **not** claim to be an authorization boundary. Anything holding this
|
|
187
|
+
* client can still send; what it removes is the affordance, and the honest
|
|
188
|
+
* enforcement lives on the gateway.
|
|
189
|
+
*/
|
|
190
|
+
readOnly?: boolean
|
|
164
191
|
className?: string
|
|
165
192
|
}
|
|
166
193
|
|
|
@@ -251,6 +278,7 @@ export function SessionPanel({
|
|
|
251
278
|
header,
|
|
252
279
|
panelSurface = 'internal',
|
|
253
280
|
statusSurface = 'internal',
|
|
281
|
+
statusPlacement = 'top',
|
|
254
282
|
onOpenPanel,
|
|
255
283
|
onVitals,
|
|
256
284
|
transcriptVariant = 'cards',
|
|
@@ -259,6 +287,7 @@ export function SessionPanel({
|
|
|
259
287
|
onControls,
|
|
260
288
|
focusComposerOnClick = false,
|
|
261
289
|
unseen,
|
|
290
|
+
readOnly = false,
|
|
262
291
|
className,
|
|
263
292
|
}: SessionPanelProps) {
|
|
264
293
|
const external = panelSurface === 'external'
|
|
@@ -503,11 +532,26 @@ export function SessionPanel({
|
|
|
503
532
|
const menu = external ? null : actionsMenu
|
|
504
533
|
const headerTakesActions = typeof header === 'function'
|
|
505
534
|
|
|
535
|
+
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
536
|
+
// three open handlers, and two copies of that in the tree would be two things
|
|
537
|
+
// to keep in step.
|
|
538
|
+
const statusBar = statusExternal ? null : (
|
|
539
|
+
<StatusBar
|
|
540
|
+
state={state}
|
|
541
|
+
connection={connection}
|
|
542
|
+
placement={statusPlacement}
|
|
543
|
+
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
544
|
+
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
545
|
+
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
546
|
+
actions={headerTakesActions ? undefined : menu}
|
|
547
|
+
/>
|
|
548
|
+
)
|
|
549
|
+
|
|
506
550
|
// Dead-space clicks land in the composer. Anything the user actually aimed at
|
|
507
551
|
// — a control, a link, the end of a drag-selection — keeps its own meaning;
|
|
508
552
|
// this only claims what was left over.
|
|
509
553
|
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
510
|
-
if (!focusComposerOnClick) return
|
|
554
|
+
if (!focusComposerOnClick || readOnly) return
|
|
511
555
|
const target = event.target as HTMLElement | null
|
|
512
556
|
if (target?.closest(INTERACTIVE)) return
|
|
513
557
|
if (window.getSelection()?.isCollapsed === false) return
|
|
@@ -525,16 +569,7 @@ export function SessionPanel({
|
|
|
525
569
|
onClick={handleClick}
|
|
526
570
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
527
571
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
528
|
-
{
|
|
529
|
-
<StatusBar
|
|
530
|
-
state={state}
|
|
531
|
-
connection={connection}
|
|
532
|
-
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
533
|
-
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
534
|
-
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
535
|
-
actions={headerTakesActions ? undefined : menu}
|
|
536
|
-
/>
|
|
537
|
-
)}
|
|
572
|
+
{statusPlacement === 'top' ? statusBar : null}
|
|
538
573
|
{protocolMismatch !== undefined ? (
|
|
539
574
|
<Notice level='warning'>
|
|
540
575
|
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
|
|
@@ -569,7 +604,12 @@ export function SessionPanel({
|
|
|
569
604
|
<div
|
|
570
605
|
data-slot='catch-up'
|
|
571
606
|
className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
|
|
572
|
-
<span
|
|
607
|
+
<span
|
|
608
|
+
aria-hidden
|
|
609
|
+
className={cn(
|
|
610
|
+
'select-none',
|
|
611
|
+
transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
|
|
612
|
+
)}>
|
|
573
613
|
※
|
|
574
614
|
</span>
|
|
575
615
|
<span className='min-w-0 flex-1 truncate'>
|
|
@@ -594,7 +634,10 @@ export function SessionPanel({
|
|
|
594
634
|
{/* An engine with no approval channel never raises these, but a stale
|
|
595
635
|
pending request from a replayed log would still render — the record is
|
|
596
636
|
the authority on whether an approval UI means anything here. */}
|
|
597
|
-
{
|
|
637
|
+
{/* A read-only surface has no answer to give: the status bar still says
|
|
638
|
+
`awaiting approval`, and the place that can act on it is wherever the
|
|
639
|
+
session is actually driven. */}
|
|
640
|
+
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
598
641
|
<div className='px-3 pb-2'>
|
|
599
642
|
<div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
|
|
600
643
|
{state.pendingApprovals.map((request) =>
|
|
@@ -618,6 +661,7 @@ export function SessionPanel({
|
|
|
618
661
|
</div>
|
|
619
662
|
</div>
|
|
620
663
|
) : null}
|
|
664
|
+
{readOnly ? null : (
|
|
621
665
|
<Composer
|
|
622
666
|
ref={composerRef}
|
|
623
667
|
onSend={handleSend}
|
|
@@ -662,6 +706,10 @@ export function SessionPanel({
|
|
|
662
706
|
)
|
|
663
707
|
}
|
|
664
708
|
/>
|
|
709
|
+
)}
|
|
710
|
+
{/* Below the composer, along the foot of the panel — the editor
|
|
711
|
+
convention. Last in the flex column, so it is the bottom edge. */}
|
|
712
|
+
{statusPlacement === 'bottom' ? statusBar : null}
|
|
665
713
|
|
|
666
714
|
{/* The internal dialog surface. The external one renders none of these —
|
|
667
715
|
the embedder hosts equivalent surfaces and is handed the intents. */}
|
|
@@ -34,7 +34,13 @@ export interface SessionWorkspaceProps {
|
|
|
34
34
|
*/
|
|
35
35
|
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
36
|
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
37
|
+
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
38
|
+
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
37
39
|
unseen?: SessionPanelProps['unseen']
|
|
40
|
+
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
41
|
+
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
42
|
+
* a run's files is the point of a read-only view. */
|
|
43
|
+
readOnly?: SessionPanelProps['readOnly']
|
|
38
44
|
onVitals?: SessionPanelProps['onVitals']
|
|
39
45
|
/** Rail width in pixels on first render. */
|
|
40
46
|
defaultRailWidth?: number
|
|
@@ -85,7 +91,9 @@ export function SessionWorkspace({
|
|
|
85
91
|
header,
|
|
86
92
|
transcriptVariant,
|
|
87
93
|
transcriptDensity,
|
|
94
|
+
statusPlacement,
|
|
88
95
|
unseen,
|
|
96
|
+
readOnly,
|
|
89
97
|
onVitals,
|
|
90
98
|
defaultRailWidth = 260,
|
|
91
99
|
defaultRailCollapsed,
|
|
@@ -264,7 +272,9 @@ export function SessionWorkspace({
|
|
|
264
272
|
header={hoisted}
|
|
265
273
|
transcriptVariant={transcriptVariant}
|
|
266
274
|
transcriptDensity={transcriptDensity}
|
|
275
|
+
statusPlacement={statusPlacement}
|
|
267
276
|
unseen={unseen}
|
|
277
|
+
readOnly={readOnly}
|
|
268
278
|
onVitals={onVitals}
|
|
269
279
|
className='min-h-0 flex-1'
|
|
270
280
|
/>
|