@workerdeck/ui 0.11.0 → 0.12.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-CyhygZx_.d.mts → SessionPanel-Dy9lQrOV.d.mts} +44 -2
- package/build/{SessionPanel-_U8tjX29.mjs → SessionPanel-NQ8ksCfj.mjs} +280 -215
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- 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 +67 -7
- package/build/index.mjs +330 -5
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +28 -1
- package/build/workspace.mjs +14 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Loader.tsx +8 -46
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionPanel.tsx +28 -1
- package/src/components/agent/SessionWorkspace.tsx +35 -0
- package/src/components/agent/ToolCallCard.tsx +11 -2
- package/src/components/agent/Transcript.tsx +26 -8
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +62 -0
- package/src/format.ts +1 -0
- package/src/index.ts +4 -0
- package/src/lib/status.ts +124 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +0 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Pencil, Search, Trash2, X } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
STATE_LABELS,
|
|
5
|
+
STATE_ORDER,
|
|
6
|
+
adaptersOf,
|
|
7
|
+
clearFilters,
|
|
8
|
+
filterRows,
|
|
9
|
+
groupRows,
|
|
10
|
+
hasFacetFilter,
|
|
11
|
+
sessionLabel,
|
|
12
|
+
subsetSummary,
|
|
13
|
+
} from '@workerdeck/protocol'
|
|
14
|
+
import type {
|
|
15
|
+
GroupBy,
|
|
16
|
+
SessionRow,
|
|
17
|
+
SessionState,
|
|
18
|
+
SortBy,
|
|
19
|
+
ViewConfig,
|
|
20
|
+
WorkspaceScope,
|
|
21
|
+
} from '@workerdeck/protocol'
|
|
22
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
23
|
+
import { Button } from '../ui/Button.tsx'
|
|
24
|
+
import { Input } from '../ui/Input.tsx'
|
|
25
|
+
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
26
|
+
import { cn } from '../../lib/utils.ts'
|
|
27
|
+
import { formatCost, formatRelativeTime } from '../../lib/format.ts'
|
|
28
|
+
import { STATUS_META } from './status.ts'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A sessions list with the affordances a list of thirty needs: search, facets,
|
|
32
|
+
* grouping, sorting, unread counts, and one honest line about what is hidden.
|
|
33
|
+
*
|
|
34
|
+
* The *rules* are `@workerdeck/protocol`'s (`filterRows`/`groupRows`/
|
|
35
|
+
* `subsetSummary`), not this component's — the VS Code sidebar renders the same
|
|
36
|
+
* model with workbench chrome, its activity-bar badge counts the same rows this
|
|
37
|
+
* would show, and iOS mirrors them in Swift. What lives here is the styled
|
|
38
|
+
* rendering of that model, so a host that wants the dashboard's look gets it
|
|
39
|
+
* without reimplementing the model behind it.
|
|
40
|
+
*
|
|
41
|
+
* `SessionList` remains beside this for the plain case (a fixed set of rows, no
|
|
42
|
+
* controls); this is what you reach for when the list is the screen.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface SessionBrowserProps {
|
|
46
|
+
rows: SessionRow[]
|
|
47
|
+
config: ViewConfig
|
|
48
|
+
onConfigChange: (config: ViewConfig) => void
|
|
49
|
+
/** The host's own folders, if it has such a notion. Absent — a dashboard, a
|
|
50
|
+
* phone — makes the scope filter genuinely inert rather than empty. */
|
|
51
|
+
scope?: WorkspaceScope
|
|
52
|
+
activeId?: string
|
|
53
|
+
onSelect?: (row: SessionRow) => void
|
|
54
|
+
onDelete?: (row: SessionRow) => void
|
|
55
|
+
/**
|
|
56
|
+
* Rename, from the row's pencil. Empty string restores the derived title. A
|
|
57
|
+
* gateway edit (`PATCH /sessions/:id`), never a local override — every client
|
|
58
|
+
* should see the same name. Omit to make titles read-only.
|
|
59
|
+
*
|
|
60
|
+
* A hover affordance rather than the extension's double-click-the-title,
|
|
61
|
+
* because here a single click on the row navigates: the *first* click of a
|
|
62
|
+
* double-click would have already left the page.
|
|
63
|
+
*/
|
|
64
|
+
onRename?: (row: SessionRow, title: string) => void
|
|
65
|
+
/** Rendered when nothing at all exists (as opposed to nothing matching). */
|
|
66
|
+
emptyState?: React.ReactNode
|
|
67
|
+
className?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SessionBrowser({
|
|
71
|
+
rows,
|
|
72
|
+
config,
|
|
73
|
+
onConfigChange,
|
|
74
|
+
scope,
|
|
75
|
+
activeId,
|
|
76
|
+
onSelect,
|
|
77
|
+
onDelete,
|
|
78
|
+
onRename,
|
|
79
|
+
emptyState,
|
|
80
|
+
className,
|
|
81
|
+
}: SessionBrowserProps) {
|
|
82
|
+
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
83
|
+
const groups = useMemo(() => groupRows(visible, config), [visible, config])
|
|
84
|
+
const subset = subsetSummary(config, scope, visible.length, rows.length)
|
|
85
|
+
// Derived, not enumerated: a new engine or a new gateway needs no change here,
|
|
86
|
+
// and a facet with one possible value is not a choice worth showing.
|
|
87
|
+
const adapters = useMemo(() => adaptersOf(rows), [rows])
|
|
88
|
+
const gateways = useMemo(() => {
|
|
89
|
+
const seen = new Map<string, string>()
|
|
90
|
+
for (const row of rows) seen.set(row.hostId, row.hostName)
|
|
91
|
+
return [...seen].map(([id, name]) => ({ id, name }))
|
|
92
|
+
}, [rows])
|
|
93
|
+
|
|
94
|
+
const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<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'>
|
|
100
|
+
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
101
|
+
<Input
|
|
102
|
+
value={config.search}
|
|
103
|
+
onChange={(e) => set({ search: e.target.value })}
|
|
104
|
+
placeholder='Search sessions'
|
|
105
|
+
aria-label='Search sessions'
|
|
106
|
+
className='pl-8'
|
|
107
|
+
/>
|
|
108
|
+
</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 ? (
|
|
116
|
+
<FacetSelect
|
|
117
|
+
label='Engine'
|
|
118
|
+
value={config.adapters}
|
|
119
|
+
options={adapters.map((a) => ({ value: a, label: a }))}
|
|
120
|
+
onChange={(adapters) => set({ adapters })}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
{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
|
+
/>
|
|
130
|
+
) : 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
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{subset ? (
|
|
156
|
+
<div className='flex items-center gap-2 text-label text-fg-4'>
|
|
157
|
+
<span>
|
|
158
|
+
{subset.shown} of {subset.total}
|
|
159
|
+
{subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
|
|
160
|
+
</span>
|
|
161
|
+
<button
|
|
162
|
+
type='button'
|
|
163
|
+
className='text-fg-3 underline underline-offset-2 hover:text-fg-1'
|
|
164
|
+
onClick={() => onConfigChange(clearFilters(config))}>
|
|
165
|
+
Show all
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
|
|
170
|
+
{rows.length === 0 ? (
|
|
171
|
+
(emptyState ?? <Empty>No sessions yet.</Empty>)
|
|
172
|
+
) : visible.length === 0 ? (
|
|
173
|
+
// 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>
|
|
190
|
+
) : (
|
|
191
|
+
<div className='flex flex-col gap-4'>
|
|
192
|
+
{groups.map((group) => (
|
|
193
|
+
<div key={group.key} className='flex flex-col gap-1'>
|
|
194
|
+
{config.groupBy !== 'none' && group.label ? (
|
|
195
|
+
<div className='flex items-baseline gap-2 px-2.5 text-label font-medium text-fg-4'>
|
|
196
|
+
<span className='uppercase tracking-wide'>{group.label}</span>
|
|
197
|
+
<span className='text-fg-4/70'>{group.rows.length}</span>
|
|
198
|
+
</div>
|
|
199
|
+
) : null}
|
|
200
|
+
{group.rows.map((row) => (
|
|
201
|
+
<SessionRowItem
|
|
202
|
+
key={`${row.hostId}:${row.info.id}`}
|
|
203
|
+
row={row}
|
|
204
|
+
active={row.info.id === activeId}
|
|
205
|
+
showGateway={gateways.length > 1}
|
|
206
|
+
onSelect={onSelect}
|
|
207
|
+
onDelete={onDelete}
|
|
208
|
+
onRename={onRename}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
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
|
+
interface SessionRowItemProps {
|
|
224
|
+
row: SessionRow
|
|
225
|
+
active?: boolean
|
|
226
|
+
showGateway?: boolean
|
|
227
|
+
onSelect?: (row: SessionRow) => void
|
|
228
|
+
onDelete?: (row: SessionRow) => void
|
|
229
|
+
onRename?: (row: SessionRow, title: string) => void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function SessionRowItem({
|
|
233
|
+
row,
|
|
234
|
+
active,
|
|
235
|
+
showGateway,
|
|
236
|
+
onSelect,
|
|
237
|
+
onDelete,
|
|
238
|
+
onRename,
|
|
239
|
+
}: SessionRowItemProps) {
|
|
240
|
+
const { info } = row
|
|
241
|
+
const meta = STATUS_META[info.status]
|
|
242
|
+
const [editing, setEditing] = useState(false)
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
data-slot='session-row'
|
|
247
|
+
data-active={active || undefined}
|
|
248
|
+
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',
|
|
251
|
+
)}>
|
|
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>
|
|
285
|
+
<button
|
|
286
|
+
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>
|
|
296
|
+
</button>
|
|
297
|
+
</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
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
|
|
324
|
+
* that is still inside this document: switching windows must not silently commit,
|
|
325
|
+
* nor kill the editor in the frame it opened.
|
|
326
|
+
*/
|
|
327
|
+
function NameEditor({
|
|
328
|
+
initial,
|
|
329
|
+
onCommit,
|
|
330
|
+
onCancel,
|
|
331
|
+
}: {
|
|
332
|
+
initial: string
|
|
333
|
+
onCommit: (title: string) => void
|
|
334
|
+
onCancel: () => void
|
|
335
|
+
}) {
|
|
336
|
+
const [value, setValue] = useState(initial)
|
|
337
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
ref.current?.select()
|
|
340
|
+
}, [])
|
|
341
|
+
return (
|
|
342
|
+
<input
|
|
343
|
+
ref={ref}
|
|
344
|
+
value={value}
|
|
345
|
+
autoFocus
|
|
346
|
+
aria-label='Session name'
|
|
347
|
+
// The row is a button; a click in here must not select the session.
|
|
348
|
+
onClick={(e) => e.stopPropagation()}
|
|
349
|
+
onChange={(e) => setValue(e.target.value)}
|
|
350
|
+
onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
|
|
351
|
+
onKeyDown={(e) => {
|
|
352
|
+
if (e.key === 'Enter') onCommit(value.trim())
|
|
353
|
+
else if (e.key === 'Escape') onCancel()
|
|
354
|
+
e.stopPropagation()
|
|
355
|
+
}}
|
|
356
|
+
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'
|
|
357
|
+
/>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A multi-select facet. Empty = no filter, which is why the trigger reads the
|
|
362
|
+
* facet's name rather than "All": nothing is being excluded. */
|
|
363
|
+
function FacetSelect({
|
|
364
|
+
label,
|
|
365
|
+
value,
|
|
366
|
+
options,
|
|
367
|
+
onChange,
|
|
368
|
+
}: {
|
|
369
|
+
label: string
|
|
370
|
+
value: string[]
|
|
371
|
+
options: { value: string; label: string }[]
|
|
372
|
+
onChange: (value: string[]) => void
|
|
373
|
+
}) {
|
|
374
|
+
return (
|
|
375
|
+
<div className='flex items-center gap-1'>
|
|
376
|
+
<Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
|
|
377
|
+
<SelectTrigger aria-label={label} className='min-w-28'>
|
|
378
|
+
<SelectValue>
|
|
379
|
+
{value.length === 0
|
|
380
|
+
? label
|
|
381
|
+
: value.length === 1
|
|
382
|
+
? (options.find((o) => o.value === value[0])?.label ?? label)
|
|
383
|
+
: `${label} · ${value.length}`}
|
|
384
|
+
</SelectValue>
|
|
385
|
+
</SelectTrigger>
|
|
386
|
+
<SelectContent>
|
|
387
|
+
{options.map((option) => (
|
|
388
|
+
<SelectItem key={option.value} value={option.value}>
|
|
389
|
+
<SelectItemText>{option.label}</SelectItemText>
|
|
390
|
+
</SelectItem>
|
|
391
|
+
))}
|
|
392
|
+
</SelectContent>
|
|
393
|
+
</Select>
|
|
394
|
+
{value.length > 0 ? (
|
|
395
|
+
<Button variant='ghost' size='icon-sm' aria-label={`Clear ${label}`} onClick={() => onChange([])}>
|
|
396
|
+
<X className='size-3 text-fg-4' />
|
|
397
|
+
</Button>
|
|
398
|
+
) : null}
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function OneOfSelect({
|
|
404
|
+
label,
|
|
405
|
+
value,
|
|
406
|
+
options,
|
|
407
|
+
onChange,
|
|
408
|
+
}: {
|
|
409
|
+
label: string
|
|
410
|
+
value: string
|
|
411
|
+
options: readonly { value: string; label: string }[]
|
|
412
|
+
onChange: (value: string) => void
|
|
413
|
+
}) {
|
|
414
|
+
return (
|
|
415
|
+
<Select value={value} onValueChange={(v) => onChange(v as string)}>
|
|
416
|
+
<SelectTrigger aria-label={label} className='min-w-28'>
|
|
417
|
+
<SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
|
|
418
|
+
</SelectTrigger>
|
|
419
|
+
<SelectContent>
|
|
420
|
+
{options.map((option) => (
|
|
421
|
+
<SelectItem key={option.value} value={option.value}>
|
|
422
|
+
<SelectItemText>{option.label}</SelectItemText>
|
|
423
|
+
</SelectItem>
|
|
424
|
+
))}
|
|
425
|
+
</SelectContent>
|
|
426
|
+
</Select>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
@@ -49,7 +49,12 @@ import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
|
49
49
|
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
50
50
|
import { StatusBar } from './StatusBar.tsx'
|
|
51
51
|
import { Transcript } from './Transcript.tsx'
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
TranscriptDensityProvider,
|
|
54
|
+
TranscriptVariantProvider,
|
|
55
|
+
type TranscriptDensity,
|
|
56
|
+
type TranscriptVariant,
|
|
57
|
+
} from './transcript-variant.tsx'
|
|
53
58
|
import { UsageDialog } from './UsageDialog.tsx'
|
|
54
59
|
|
|
55
60
|
export interface SessionPanelProps {
|
|
@@ -107,6 +112,14 @@ export interface SessionPanelProps {
|
|
|
107
112
|
* (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
|
|
108
113
|
*/
|
|
109
114
|
transcriptVariant?: TranscriptVariant
|
|
115
|
+
/**
|
|
116
|
+
* How much air the transcript gives each row — `'comfortable'` (default: a
|
|
117
|
+
* blank line between messages, as the Claude Code CLI leaves) or `'compact'`
|
|
118
|
+
* (rows tight against one another). Independent of `transcriptVariant`: the
|
|
119
|
+
* variant follows from the surface, density is the reader's preference, and a
|
|
120
|
+
* dock is allowed to be roomy.
|
|
121
|
+
*/
|
|
122
|
+
transcriptDensity?: TranscriptDensity
|
|
110
123
|
/**
|
|
111
124
|
* Where the session's own controls — model and permission mode — live.
|
|
112
125
|
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
@@ -156,6 +169,15 @@ export type SessionControls = {
|
|
|
156
169
|
setModel: (model?: string) => void
|
|
157
170
|
setPermissionMode: (mode: PermissionMode) => void
|
|
158
171
|
interrupt: () => void
|
|
172
|
+
/**
|
|
173
|
+
* Put the caret in the composer.
|
|
174
|
+
*
|
|
175
|
+
* For an embedder whose own chrome is how you arrive at a session — clicking a
|
|
176
|
+
* row in VS Code's sidebar — where revealing the panel and being able to type
|
|
177
|
+
* are the same intention. The panel cannot infer it: from in here, a session
|
|
178
|
+
* appearing looks identical whether someone asked for it or it was restored.
|
|
179
|
+
*/
|
|
180
|
+
focusComposer: () => void
|
|
159
181
|
}
|
|
160
182
|
|
|
161
183
|
/** Everything a click can mean other than "put the caret in the composer".
|
|
@@ -232,6 +254,7 @@ export function SessionPanel({
|
|
|
232
254
|
onOpenPanel,
|
|
233
255
|
onVitals,
|
|
234
256
|
transcriptVariant = 'cards',
|
|
257
|
+
transcriptDensity = 'comfortable',
|
|
235
258
|
controlsSurface = 'internal',
|
|
236
259
|
onControls,
|
|
237
260
|
focusComposerOnClick = false,
|
|
@@ -358,6 +381,7 @@ export function SessionPanel({
|
|
|
358
381
|
setModel: (model) => setters.current.setModel(model),
|
|
359
382
|
setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
|
|
360
383
|
interrupt: () => setters.current.interrupt(),
|
|
384
|
+
focusComposer: () => composerRef.current?.focus(),
|
|
361
385
|
})
|
|
362
386
|
useEffect(() => {
|
|
363
387
|
const handler = onControlsRef.current
|
|
@@ -495,6 +519,7 @@ export function SessionPanel({
|
|
|
495
519
|
// and question prompts live outside the scroller but are line items in the
|
|
496
520
|
// same run, and they read `useLines()` like every other row.
|
|
497
521
|
<TranscriptVariantProvider value={transcriptVariant}>
|
|
522
|
+
<TranscriptDensityProvider value={transcriptDensity}>
|
|
498
523
|
<div
|
|
499
524
|
data-slot='session-panel'
|
|
500
525
|
onClick={handleClick}
|
|
@@ -528,6 +553,7 @@ export function SessionPanel({
|
|
|
528
553
|
canBrowseFiles={hostFiles.available}
|
|
529
554
|
hostImage={hostImage}
|
|
530
555
|
variant={transcriptVariant}
|
|
556
|
+
density={transcriptDensity}
|
|
531
557
|
catchUp={
|
|
532
558
|
catchUp && newCount > 0
|
|
533
559
|
? { from: catchUp.itemCount, since: catchUp.since }
|
|
@@ -686,6 +712,7 @@ export function SessionPanel({
|
|
|
686
712
|
</>
|
|
687
713
|
) : null}
|
|
688
714
|
</div>
|
|
715
|
+
</TranscriptDensityProvider>
|
|
689
716
|
</TranscriptVariantProvider>
|
|
690
717
|
)
|
|
691
718
|
}
|
|
@@ -24,10 +24,29 @@ export interface SessionWorkspaceProps {
|
|
|
24
24
|
/** Passed straight through to {@link SessionPanel} — including the render-prop
|
|
25
25
|
* form that claims the session-actions menu. */
|
|
26
26
|
header?: SessionPanelProps['header']
|
|
27
|
+
/**
|
|
28
|
+
* Panel seams the workspace does not interpret, forwarded verbatim.
|
|
29
|
+
*
|
|
30
|
+
* They are listed rather than spread so the workspace stays explicit about
|
|
31
|
+
* what it passes on: the panel owns the session's one attach, and a seam that
|
|
32
|
+
* silently arrived here would be a second place to look for why a session
|
|
33
|
+
* renders the way it does.
|
|
34
|
+
*/
|
|
35
|
+
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
|
+
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
37
|
+
unseen?: SessionPanelProps['unseen']
|
|
38
|
+
onVitals?: SessionPanelProps['onVitals']
|
|
27
39
|
/** Rail width in pixels on first render. */
|
|
28
40
|
defaultRailWidth?: number
|
|
29
41
|
/** Start with the file rail collapsed even on a wide viewport. */
|
|
30
42
|
defaultRailCollapsed?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* The rail moved. Paired with the two defaults so an embedder can persist the
|
|
45
|
+
* layout — the workspace deliberately does not, because *where* to keep it (a
|
|
46
|
+
* Memento, localStorage, a workspace file) is the embedder's call, and a
|
|
47
|
+
* component that picked one would be wrong in the other hosts.
|
|
48
|
+
*/
|
|
49
|
+
onRailChange?: (rail: { width: number; collapsed: boolean }) => void
|
|
31
50
|
className?: string
|
|
32
51
|
}
|
|
33
52
|
|
|
@@ -64,8 +83,13 @@ export function SessionWorkspace({
|
|
|
64
83
|
client,
|
|
65
84
|
sessionId,
|
|
66
85
|
header,
|
|
86
|
+
transcriptVariant,
|
|
87
|
+
transcriptDensity,
|
|
88
|
+
unseen,
|
|
89
|
+
onVitals,
|
|
67
90
|
defaultRailWidth = 260,
|
|
68
91
|
defaultRailCollapsed,
|
|
92
|
+
onRailChange,
|
|
69
93
|
className,
|
|
70
94
|
}: SessionWorkspaceProps) {
|
|
71
95
|
// The cwd is the tree's root, and it comes from the registry rather than from
|
|
@@ -95,6 +119,13 @@ export function SessionWorkspace({
|
|
|
95
119
|
const wide = useIsWide()
|
|
96
120
|
const [railCollapsed, setRailCollapsed] = useState(defaultRailCollapsed ?? false)
|
|
97
121
|
const [railWidth, setRailWidth] = useState(defaultRailWidth)
|
|
122
|
+
// Reported rather than stored. Kept in a ref so the effect below fires on a
|
|
123
|
+
// real change instead of on every render an inline callback would cause.
|
|
124
|
+
const onRailChangeRef = useRef(onRailChange)
|
|
125
|
+
onRailChangeRef.current = onRailChange
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
onRailChangeRef.current?.({ width: railWidth, collapsed: railCollapsed })
|
|
128
|
+
}, [railWidth, railCollapsed])
|
|
98
129
|
const [editorHeight, setEditorHeight] = useState(360)
|
|
99
130
|
|
|
100
131
|
// Closing every tab returns the agent to the full column; opening one again
|
|
@@ -231,6 +262,10 @@ export function SessionWorkspace({
|
|
|
231
262
|
client={client}
|
|
232
263
|
sessionId={sessionId}
|
|
233
264
|
header={hoisted}
|
|
265
|
+
transcriptVariant={transcriptVariant}
|
|
266
|
+
transcriptDensity={transcriptDensity}
|
|
267
|
+
unseen={unseen}
|
|
268
|
+
onVitals={onVitals}
|
|
234
269
|
className='min-h-0 flex-1'
|
|
235
270
|
/>
|
|
236
271
|
</div>
|
|
@@ -8,6 +8,7 @@ import { cn } from '../../lib/utils.ts'
|
|
|
8
8
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
9
9
|
import { isMutatingTool, toolIcon } from '../../lib/tool-icon.ts'
|
|
10
10
|
import { LINE_INDENT, LinePayload } from './line-prompt.tsx'
|
|
11
|
+
import { usePulse } from './pulse.tsx'
|
|
11
12
|
import { LineGlyph, useLines } from './transcript-variant.tsx'
|
|
12
13
|
|
|
13
14
|
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
@@ -97,6 +98,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
97
98
|
const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
98
99
|
const badge = STATE_BADGE[status]
|
|
99
100
|
const isError = status === 'failed' || item.result?.isError === true
|
|
101
|
+
// Ticks only while this row is actually running, and only in the variant with a
|
|
102
|
+
// gutter to pulse in — an idle transcript of a hundred settled tools starts no
|
|
103
|
+
// timers at all.
|
|
104
|
+
const pulse = usePulse(lines && badge.busy)
|
|
100
105
|
const Icon = toolIcon(item.name)
|
|
101
106
|
|
|
102
107
|
const resultText = item.result?.text ?? ''
|
|
@@ -162,7 +167,10 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
162
167
|
? 'text-success'
|
|
163
168
|
: STATE_GLYPH[status]
|
|
164
169
|
}>
|
|
165
|
-
{
|
|
170
|
+
{/* Running: the mark's own pulse, so a working tool row and the
|
|
171
|
+
transcript's working line beat together. Settled: a plain dot,
|
|
172
|
+
which reads as "done" precisely by not moving. */}
|
|
173
|
+
{badge.busy ? pulse : '●'}
|
|
166
174
|
</LineGlyph>
|
|
167
175
|
<span className='min-w-0 flex-1 truncate text-body-sm leading-5 text-fg-3'>
|
|
168
176
|
<span className='font-medium text-fg-1'>{item.name}</span>
|
|
@@ -171,7 +179,8 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
|
|
|
171
179
|
{item.backend && item.backend !== 'server' ? (
|
|
172
180
|
<span className='shrink-0 text-label text-fg-4'>{item.backend}</span>
|
|
173
181
|
) : null}
|
|
174
|
-
{
|
|
182
|
+
{/* No Spinner here: the gutter glyph animates now, and two spinners on
|
|
183
|
+
one row is one too many. `cards` keeps its own — it has no gutter. */}
|
|
175
184
|
{status === 'deferred' ? <Clock className='size-3 shrink-0 self-center text-fg-4' /> : null}
|
|
176
185
|
{isError && !badge.busy ? (
|
|
177
186
|
<span className='shrink-0 text-label text-danger'>error</span>
|