@workerdeck/ui 0.19.0 → 0.21.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-CnU_IJ3-.d.mts → SessionPanel-BjNAnWMF.d.mts} +41 -3
- package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DNuPKg5c.mjs} +20 -12
- package/build/{SessionPanel-CHktI2CF.mjs.map → SessionPanel-DNuPKg5c.mjs.map} +1 -1
- package/build/index.d.mts +269 -33
- package/build/index.mjs +530 -381
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +10 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +9 -5
- package/src/components/agent/SessionBrowser.tsx +131 -278
- package/src/components/agent/SessionItem.tsx +538 -0
- package/src/components/agent/SessionPanel.tsx +92 -14
- package/src/components/agent/SessionStatusIcon.tsx +43 -0
- package/src/components/agent/SessionSteps.tsx +108 -57
- package/src/components/agent/SessionWorkspace.tsx +11 -0
- package/src/index.ts +5 -1
- package/src/styles/theme.css +63 -8
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import {
|
|
4
|
-
BellRing,
|
|
5
|
-
CircleAlert,
|
|
6
|
-
CircleSlash,
|
|
7
|
-
Layers,
|
|
8
|
-
Moon,
|
|
9
|
-
PauseCircle,
|
|
10
|
-
Pencil,
|
|
11
|
-
Search,
|
|
12
|
-
SearchX,
|
|
13
|
-
Trash2,
|
|
14
|
-
X,
|
|
15
|
-
} from 'lucide-react'
|
|
3
|
+
import { Eraser, Layers, Pencil, Search, SearchX, Trash2, X } from 'lucide-react'
|
|
16
4
|
import {
|
|
17
5
|
STATE_LABELS,
|
|
18
6
|
STATE_ORDER,
|
|
@@ -21,10 +9,7 @@ import {
|
|
|
21
9
|
filterRows,
|
|
22
10
|
groupRows,
|
|
23
11
|
hasFacetFilter,
|
|
24
|
-
projectLabel,
|
|
25
|
-
projectSubpath,
|
|
26
12
|
projectsOf,
|
|
27
|
-
sessionLabel,
|
|
28
13
|
subsetSummary,
|
|
29
14
|
} from '@workerdeck/protocol'
|
|
30
15
|
import type {
|
|
@@ -39,13 +24,14 @@ import { Button } from '../ui/Button.tsx'
|
|
|
39
24
|
import { Empty } from '../ui/Empty.tsx'
|
|
40
25
|
import { Input } from '../ui/Input.tsx'
|
|
41
26
|
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
42
|
-
import { Spinner } from '../ui/Spinner.tsx'
|
|
43
|
-
import { ContextRing } from './ContextRing.tsx'
|
|
44
|
-
import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
|
|
45
27
|
import { ProjectIcon } from './ProjectIcon.tsx'
|
|
46
|
-
import {
|
|
28
|
+
import { SessionItem } from './SessionItem.tsx'
|
|
47
29
|
import { cn } from '../../lib/utils.ts'
|
|
48
|
-
|
|
30
|
+
|
|
31
|
+
// The state glyph moved to its own module when the card did — the extension's
|
|
32
|
+
// list draws it too, and this file re-exports it only so the package's public
|
|
33
|
+
// surface does not shift under a host that imports it from here.
|
|
34
|
+
export { SessionStatusIcon } from './SessionStatusIcon.tsx'
|
|
49
35
|
|
|
50
36
|
/**
|
|
51
37
|
* A sessions list with the affordances a list of thirty needs: search, facets,
|
|
@@ -70,6 +56,13 @@ export interface SessionBrowserProps {
|
|
|
70
56
|
* phone — makes the scope filter genuinely inert rather than empty. */
|
|
71
57
|
scope?: WorkspaceScope
|
|
72
58
|
activeId?: string
|
|
59
|
+
/**
|
|
60
|
+
* Which sub-agent the host currently has open, by tool-use id — the *finer*
|
|
61
|
+
* of the two selections. The card holding it drops to the secondary grey and
|
|
62
|
+
* that step takes the blue; see `SessionItem`. A host with no sub-agent
|
|
63
|
+
* surface leaves it undefined and nothing changes.
|
|
64
|
+
*/
|
|
65
|
+
activeSubagentId?: string
|
|
73
66
|
onSelect?: (row: SessionRow) => void
|
|
74
67
|
onDelete?: (row: SessionRow) => void
|
|
75
68
|
/**
|
|
@@ -82,10 +75,27 @@ export interface SessionBrowserProps {
|
|
|
82
75
|
* double-click would have already left the page.
|
|
83
76
|
*/
|
|
84
77
|
onRename?: (row: SessionRow, title: string) => void
|
|
78
|
+
/**
|
|
79
|
+
* Clear the conversation in place, from the row's eraser. Same session, empty
|
|
80
|
+
* context — **not** a delete: the engine keeps the old conversation and it
|
|
81
|
+
* stays resumable, which is why the row's copy never says "deleted".
|
|
82
|
+
*
|
|
83
|
+
* Rendered only where the session's own capability record allows it
|
|
84
|
+
* (`capabilities.clearContext`, absent = false), so a row that cannot be
|
|
85
|
+
* cleared has no eraser rather than one that errors. Omit the prop to have
|
|
86
|
+
* none at all — a host with no way to send a session command has nothing to
|
|
87
|
+
* hang it on (this is a WS command, not a REST route).
|
|
88
|
+
*/
|
|
89
|
+
onClearContext?: (row: SessionRow) => void
|
|
85
90
|
/** Open a session *at* one of its sub-agents, when the host can scroll a
|
|
86
91
|
* transcript to a `Task` row. Absent, the sub-agent list still expands and a
|
|
87
92
|
* step just opens its session — see `SessionRowItemProps`. */
|
|
88
93
|
onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
|
|
94
|
+
/** Open a session and travel to one of its **tasks** — see
|
|
95
|
+
* `SessionItem.onRevealStep`. A task has no agent behind it, so it is a place
|
|
96
|
+
* to go rather than a thing to open, and framing it as an agent drew an empty
|
|
97
|
+
* view. */
|
|
98
|
+
onRevealStep?: (row: SessionRow, toolUseId: string) => void
|
|
89
99
|
/** Rendered when nothing at all exists (as opposed to nothing matching). */
|
|
90
100
|
emptyState?: React.ReactNode
|
|
91
101
|
/**
|
|
@@ -141,10 +151,13 @@ export function SessionBrowser({
|
|
|
141
151
|
onConfigChange,
|
|
142
152
|
scope,
|
|
143
153
|
activeId,
|
|
154
|
+
activeSubagentId,
|
|
144
155
|
onSelect,
|
|
145
156
|
onDelete,
|
|
146
157
|
onRename,
|
|
158
|
+
onClearContext,
|
|
147
159
|
onSelectSubagent,
|
|
160
|
+
onRevealStep,
|
|
148
161
|
emptyState,
|
|
149
162
|
showControls = true,
|
|
150
163
|
projectIcons,
|
|
@@ -285,11 +298,18 @@ export function SessionBrowser({
|
|
|
285
298
|
<Empty icon={<Layers />} title='Nothing here' description='No session to show.' />
|
|
286
299
|
)
|
|
287
300
|
) : (
|
|
288
|
-
|
|
301
|
+
/* The list's own 4px inset, as **padding on the list** rather than
|
|
302
|
+
margins on the cards. `SessionItem` is `w-full` — it fills the slot it
|
|
303
|
+
is given, which is what lets a host size it — and `w-full` plus
|
|
304
|
+
`mx-1` is `100% + 8px`: an overflow by construction, which is exactly
|
|
305
|
+
what it did. The cards ran past the sidebar's right edge and the
|
|
306
|
+
column grew a horizontal scrollbar. Padding here cannot do that,
|
|
307
|
+
whatever the card's own width rule turns out to be. */
|
|
308
|
+
<div className='flex flex-col gap-4 px-1'>
|
|
289
309
|
{groups.map((group) => (
|
|
290
310
|
<div key={group.key} className='flex flex-col gap-1'>
|
|
291
311
|
{config.groupBy !== 'none' && group.label ? (
|
|
292
|
-
<div className='flex items-center gap-2 px-
|
|
312
|
+
<div className='flex items-center gap-2 px-2 text-label font-medium text-fg-4'>
|
|
293
313
|
{/* Only the project facet has a mark of its own, and a group
|
|
294
314
|
IS one project root, so the first row is a fair source. */}
|
|
295
315
|
{config.groupBy === 'project' ? (
|
|
@@ -308,13 +328,16 @@ export function SessionBrowser({
|
|
|
308
328
|
key={`${row.hostId}:${row.info.id}`}
|
|
309
329
|
row={row}
|
|
310
330
|
active={row.info.id === activeId}
|
|
331
|
+
activeSubagentId={activeSubagentId}
|
|
311
332
|
showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
|
|
312
333
|
showProject={config.groupBy !== 'project'}
|
|
313
334
|
projectIcons={projectIcons}
|
|
314
335
|
onSelect={onSelect}
|
|
315
336
|
onDelete={onDelete}
|
|
316
337
|
onRename={onRename}
|
|
338
|
+
onClearContext={onClearContext}
|
|
317
339
|
onSelectSubagent={onSelectSubagent}
|
|
340
|
+
onRevealStep={onRevealStep}
|
|
318
341
|
/>
|
|
319
342
|
))}
|
|
320
343
|
</div>
|
|
@@ -339,6 +362,8 @@ function iconSrcOf(
|
|
|
339
362
|
interface SessionRowItemProps {
|
|
340
363
|
row: SessionRow
|
|
341
364
|
active?: boolean
|
|
365
|
+
/** See `SessionBrowserProps.activeSubagentId`. */
|
|
366
|
+
activeSubagentId?: string
|
|
342
367
|
showGateway?: boolean
|
|
343
368
|
/** False when the list is already grouped by project — the header has said the
|
|
344
369
|
* name, so the row must not spend its metadata line repeating it. What takes
|
|
@@ -352,6 +377,9 @@ interface SessionRowItemProps {
|
|
|
352
377
|
onSelect?: (row: SessionRow) => void
|
|
353
378
|
onDelete?: (row: SessionRow) => void
|
|
354
379
|
onRename?: (row: SessionRow, title: string) => void
|
|
380
|
+
/** See `SessionBrowserProps.onClearContext` — gated on the row's own
|
|
381
|
+
* capability record, not on the engine name. */
|
|
382
|
+
onClearContext?: (row: SessionRow) => void
|
|
355
383
|
/** Open one of a session's sub-agents — which now means handing the session
|
|
356
384
|
* panel over to that agent's own work (`SessionPanel.openSubagent`), and meant
|
|
357
385
|
* "scroll to its `Task` row" before that surface existed. Both are honest; a
|
|
@@ -359,290 +387,115 @@ interface SessionRowItemProps {
|
|
|
359
387
|
* feature: without it a step just opens the session, which is all a host with
|
|
360
388
|
* neither can offer. Only *agent* steps ever call it — see `Step.kind`. */
|
|
361
389
|
onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
|
|
390
|
+
/** See `SessionBrowserProps.onRevealStep`. */
|
|
391
|
+
onRevealStep?: (row: SessionRow, toolUseId: string) => void
|
|
362
392
|
}
|
|
363
393
|
|
|
364
394
|
function SessionRowItem({
|
|
365
395
|
row,
|
|
366
396
|
active,
|
|
397
|
+
activeSubagentId,
|
|
367
398
|
showGateway,
|
|
368
399
|
showProject = true,
|
|
369
400
|
projectIcons,
|
|
370
401
|
onSelect,
|
|
371
402
|
onDelete,
|
|
372
403
|
onRename,
|
|
404
|
+
onClearContext,
|
|
373
405
|
onSelectSubagent,
|
|
406
|
+
onRevealStep,
|
|
374
407
|
}: SessionRowItemProps) {
|
|
375
408
|
const { info } = row
|
|
409
|
+
// The rename affordance here is a pencil, not the card's own double-click:
|
|
410
|
+
// the dashboard already spends the row's hover on two other actions, and a
|
|
411
|
+
// gesture that only one of the three responds to is a gesture nobody finds.
|
|
412
|
+
// So the trigger is `external` and this owns the flag.
|
|
376
413
|
const [editing, setEditing] = useState(false)
|
|
377
|
-
// Expansion is the row's own state and deliberately not persisted: a list that
|
|
378
|
-
// reopened yesterday's work on every reload would be showing a settled tail
|
|
379
|
-
// nobody asked for.
|
|
380
|
-
const [expanded, setExpanded] = useState(false)
|
|
381
|
-
|
|
382
|
-
// What it is and what it has spent, in one line — the same set the extension
|
|
383
|
-
// shows, joined the same way, so the two lists read as one product.
|
|
384
|
-
// protocol's own spelling, so this row, the group header above it and the
|
|
385
|
-
// project facet cannot disagree about what a project is called. Under a
|
|
386
|
-
// project group it is the sub-path instead — see `showProject` — and a session
|
|
387
|
-
// at the root contributes nothing at all rather than a name already on screen.
|
|
388
|
-
const project = showProject ? projectLabel(row) : projectSubpath(row)
|
|
389
|
-
const projectIcon = showProject ? info.project?.icon : undefined
|
|
390
|
-
const engine = info.engine ?? 'claude'
|
|
391
|
-
// Everything after the model. The model itself is drawn separately because it
|
|
392
|
-
// is the one segment that wears a colour — see `vendorTextClass` — and a
|
|
393
|
-
// coloured run inside a joined string would have to be spliced back out.
|
|
394
|
-
const details = [
|
|
395
|
-
project,
|
|
396
|
-
showGateway ? row.hostName : undefined,
|
|
397
|
-
info.profile ? `@${info.profile}` : undefined,
|
|
398
|
-
formatCost(info.totalCostUsd),
|
|
399
|
-
].filter(Boolean)
|
|
400
|
-
const steps = sessionSteps(info, (toolUseId) =>
|
|
401
|
-
onSelectSubagent ? onSelectSubagent(row, toolUseId) : onSelect?.(row),
|
|
402
|
-
)
|
|
403
|
-
// Where the icon goes: immediately before the project's own name, wherever
|
|
404
|
-
// that landed in the joined line. Split rather than interleaved as nodes,
|
|
405
|
-
// because everything here is one truncating mono run and a flex of pieces
|
|
406
|
-
// would each shrink a little and leave several half-words.
|
|
407
|
-
const cut = project === undefined ? -1 : details.indexOf(project)
|
|
408
414
|
|
|
409
415
|
return (
|
|
410
|
-
<
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
<button
|
|
453
|
-
type='button'
|
|
454
|
-
className={cn(
|
|
455
|
-
'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
|
|
456
|
-
active ? 'font-medium text-fg-1' : 'text-fg-2',
|
|
457
|
-
)}>
|
|
458
|
-
{sessionLabel(info)}
|
|
459
|
-
</button>
|
|
460
|
-
)}
|
|
461
|
-
{row.unseen > 0 ? (
|
|
462
|
-
// Transcript rows since this session was last on screen — the same
|
|
463
|
-
// unit the VS Code badge counts, because turns undercount badly.
|
|
464
|
-
<span
|
|
465
|
-
title={`${row.unseen} new`}
|
|
466
|
-
className='shrink-0 rounded-full bg-accent px-1.5 text-label text-accent-fg'>
|
|
467
|
-
{row.unseen}
|
|
468
|
-
</span>
|
|
469
|
-
) : null}
|
|
470
|
-
<span className='shrink-0 text-label text-fg-4'>
|
|
471
|
-
{formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
|
|
472
|
-
</span>
|
|
473
|
-
<ContextRing usage={info.contextUsage} />
|
|
474
|
-
</div>
|
|
475
|
-
|
|
476
|
-
{/* Line two: what it is — the engine's mark and its model in the vendor's
|
|
477
|
-
own colour, then where it runs — with the actions at the far right,
|
|
478
|
-
away from the title you are actually reading. */}
|
|
479
|
-
<div className='flex items-center gap-1 text-label text-fg-4'>
|
|
480
|
-
<Gutter>
|
|
481
|
-
{/* The colour goes on the icon, not on this cell: the svg ships its
|
|
482
|
-
own `text-fg-3` and only a class on the element itself merges over
|
|
483
|
-
it. */}
|
|
484
|
-
<EngineIcon
|
|
485
|
-
engine={engine}
|
|
486
|
-
model={info.model}
|
|
487
|
-
className={vendorMarkClass(engine, info.model)}
|
|
488
|
-
/>
|
|
489
|
-
</Gutter>
|
|
490
|
-
<button
|
|
491
|
-
type='button'
|
|
492
|
-
tabIndex={-1}
|
|
493
|
-
className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
|
|
494
|
-
<span className={vendorTextClass(engine, info.model)}>
|
|
495
|
-
{/* The model id as a person says it — `claude-opus-5[1m]` is a wire
|
|
496
|
-
value, and a list line has no room for a context-window suffix. */}
|
|
497
|
-
{friendlyModel(info.model)}
|
|
498
|
-
</span>
|
|
499
|
-
{details.length > 0 ? ' · ' : ''}
|
|
500
|
-
{cut < 0 ? (
|
|
501
|
-
details.join(' · ')
|
|
502
|
-
) : (
|
|
503
|
-
<>
|
|
504
|
-
{details.slice(0, cut).map((part) => `${part} · `)}
|
|
505
|
-
<ProjectIcon
|
|
506
|
-
icon={projectIcon}
|
|
507
|
-
src={iconSrcOf(row, projectIcons)}
|
|
508
|
-
name={project}
|
|
509
|
-
/* Nudged onto the text baseline: a 12px glyph box against an
|
|
510
|
-
11px line sits a hair proud without it. */
|
|
511
|
-
className='mr-1 align-[-0.2em]'
|
|
512
|
-
/>
|
|
513
|
-
{details.slice(cut).join(' · ')}
|
|
514
|
-
</>
|
|
515
|
-
)}
|
|
516
|
-
</button>
|
|
517
|
-
{steps.length > 0 ? (
|
|
518
|
-
<StepToggle
|
|
519
|
-
expanded={expanded}
|
|
520
|
-
running={runningSteps(steps)}
|
|
521
|
-
total={steps.length}
|
|
522
|
-
noun={steps[0]!.noun}
|
|
523
|
-
onToggle={() => setExpanded((v) => !v)}
|
|
524
|
-
/>
|
|
525
|
-
) : null}
|
|
526
|
-
{onRename && !editing ? (
|
|
527
|
-
<Button
|
|
528
|
-
variant='ghost'
|
|
529
|
-
size='icon-sm'
|
|
530
|
-
aria-label='Rename session'
|
|
531
|
-
className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
|
|
532
|
-
onClick={(e) => {
|
|
533
|
-
e.stopPropagation()
|
|
534
|
-
setEditing(true)
|
|
535
|
-
}}>
|
|
536
|
-
<Pencil className='size-3 text-fg-3' />
|
|
537
|
-
</Button>
|
|
538
|
-
) : null}
|
|
539
|
-
{onDelete ? (
|
|
540
|
-
<Button
|
|
541
|
-
variant='ghost'
|
|
542
|
-
size='icon-sm'
|
|
543
|
-
aria-label='Close session'
|
|
544
|
-
className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
|
|
545
|
-
onClick={(e) => {
|
|
546
|
-
e.stopPropagation()
|
|
547
|
-
onDelete(row)
|
|
548
|
-
}}>
|
|
549
|
-
<Trash2 className='size-3 text-fg-3' />
|
|
550
|
-
</Button>
|
|
551
|
-
) : null}
|
|
552
|
-
</div>
|
|
553
|
-
{expanded ? steps.map((step) => <StepRow key={step.key} step={step} onSelect={step.onSelect} />) : null}
|
|
554
|
-
</div>
|
|
416
|
+
<SessionItem
|
|
417
|
+
row={row}
|
|
418
|
+
active={active === true}
|
|
419
|
+
activeStepKey={activeSubagentId}
|
|
420
|
+
showGateway={showGateway}
|
|
421
|
+
showProject={showProject}
|
|
422
|
+
projectIcons={projectIcons}
|
|
423
|
+
onSelect={() => onSelect?.(row)}
|
|
424
|
+
onSelectSubagent={onSelectSubagent ? (id) => onSelectSubagent(row, id) : undefined}
|
|
425
|
+
onRevealStep={onRevealStep ? (id) => onRevealStep(row, id) : undefined}
|
|
426
|
+
onRename={onRename ? (title) => onRename(row, title) : undefined}
|
|
427
|
+
renameOn='external'
|
|
428
|
+
editing={editing}
|
|
429
|
+
onEditingChange={setEditing}
|
|
430
|
+
actions={
|
|
431
|
+
<>
|
|
432
|
+
{onRename && !editing ? (
|
|
433
|
+
<RowAction
|
|
434
|
+
label='Rename session'
|
|
435
|
+
onClick={() => setEditing(true)}>
|
|
436
|
+
<Pencil className='size-3 text-fg-3' />
|
|
437
|
+
</RowAction>
|
|
438
|
+
) : null}
|
|
439
|
+
{/* Gated on the row's own capability record, not on the engine name —
|
|
440
|
+
an engine that grows the ability gets the button with no change
|
|
441
|
+
here, and one that loses it stops offering a control that fails. */}
|
|
442
|
+
{onClearContext && info.capabilities?.clearContext ? (
|
|
443
|
+
<RowAction
|
|
444
|
+
label='Clear context'
|
|
445
|
+
title='Clear the conversation — the session keeps running and the old conversation stays resumable'
|
|
446
|
+
onClick={() => onClearContext(row)}>
|
|
447
|
+
<Eraser className='size-3 text-fg-3' />
|
|
448
|
+
</RowAction>
|
|
449
|
+
) : null}
|
|
450
|
+
{onDelete ? (
|
|
451
|
+
<RowAction label='Close session' onClick={() => onDelete(row)}>
|
|
452
|
+
<Trash2 className='size-3 text-fg-3' />
|
|
453
|
+
</RowAction>
|
|
454
|
+
) : null}
|
|
455
|
+
</>
|
|
456
|
+
}
|
|
457
|
+
/>
|
|
555
458
|
)
|
|
556
459
|
}
|
|
557
460
|
|
|
558
461
|
/**
|
|
559
|
-
*
|
|
462
|
+
* One of the dashboard's hover actions.
|
|
560
463
|
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
464
|
+
* Hover-revealed rather than always on, which is the opposite of the
|
|
465
|
+
* extension's single overflow glyph and the right call for each: there are
|
|
466
|
+
* three of them here and a sidebar card has room for one, so the extension
|
|
467
|
+
* spends that room on a menu and this spends the row's hover on the actions
|
|
468
|
+
* themselves. Both stop the click — the whole card is pressable underneath, and
|
|
469
|
+
* an action that also selected the session would do two things per press.
|
|
565
470
|
*/
|
|
566
|
-
function
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
* State as one glyph — a ringing bell when it wants a human, a spinner while it
|
|
572
|
-
* works, a moon when it is only sleeping. Replaces the text badge: in a sidebar
|
|
573
|
-
* the word costs more room than it earns, and the states that matter are the two
|
|
574
|
-
* you can recognise without reading.
|
|
575
|
-
*
|
|
576
|
-
* **It reads `row.state`, not `info.status`, and that distinction is the whole
|
|
577
|
-
* point of the row model.** `sessionState` already folds in the arm this glyph
|
|
578
|
-
* cannot see for itself: a *background* sub-agent outlives its turn by design, so
|
|
579
|
-
* the turn ends, `status` comes to rest at `idle`, and the agent keeps working.
|
|
580
|
-
* Reading the raw status drew a **moon on a row filed under the "Working"
|
|
581
|
-
* header** — the list contradicting itself on one line, which is exactly what a
|
|
582
|
-
* derived view model exists to prevent. The value was in scope and unread.
|
|
583
|
-
*
|
|
584
|
-
* The terminal statuses still come off `info.status`, because `ended` collapses
|
|
585
|
-
* `failed` and `closed` into one bucket and those are worth telling apart here.
|
|
586
|
-
*/
|
|
587
|
-
export function SessionStatusIcon({ row }: { row: SessionRow }) {
|
|
588
|
-
const { info } = row
|
|
589
|
-
if (row.state === 'attention') {
|
|
590
|
-
return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
|
|
591
|
-
}
|
|
592
|
-
if (row.state === 'working') {
|
|
593
|
-
return <Spinner className='size-3.5 shrink-0 text-info' />
|
|
594
|
-
}
|
|
595
|
-
switch (info.status) {
|
|
596
|
-
case 'failed':
|
|
597
|
-
return <CircleAlert className='size-3.5 shrink-0 text-danger' />
|
|
598
|
-
case 'closed':
|
|
599
|
-
return <CircleSlash className='size-3.5 shrink-0 text-fg-4' />
|
|
600
|
-
case 'parked':
|
|
601
|
-
return <PauseCircle className='size-3.5 shrink-0 text-fg-3' />
|
|
602
|
-
default:
|
|
603
|
-
return <Moon className='size-3.5 shrink-0 text-fg-4' />
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
|
|
609
|
-
* that is still inside this document: switching windows must not silently commit,
|
|
610
|
-
* nor kill the editor in the frame it opened.
|
|
611
|
-
*/
|
|
612
|
-
function NameEditor({
|
|
613
|
-
initial,
|
|
614
|
-
onCommit,
|
|
615
|
-
onCancel,
|
|
471
|
+
function RowAction({
|
|
472
|
+
label,
|
|
473
|
+
title,
|
|
474
|
+
onClick,
|
|
475
|
+
children,
|
|
616
476
|
}: {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
477
|
+
label: string
|
|
478
|
+
title?: string
|
|
479
|
+
onClick: () => void
|
|
480
|
+
children: ReactNode
|
|
620
481
|
}) {
|
|
621
|
-
const [value, setValue] = useState(initial)
|
|
622
|
-
const ref = useRef<HTMLInputElement>(null)
|
|
623
|
-
useEffect(() => {
|
|
624
|
-
ref.current?.select()
|
|
625
|
-
}, [])
|
|
626
482
|
return (
|
|
627
|
-
<
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
onClick={(e) =>
|
|
634
|
-
onChange={(e) => setValue(e.target.value)}
|
|
635
|
-
onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
|
|
636
|
-
onKeyDown={(e) => {
|
|
637
|
-
if (e.key === 'Enter') onCommit(value.trim())
|
|
638
|
-
else if (e.key === 'Escape') onCancel()
|
|
483
|
+
<Button
|
|
484
|
+
variant='ghost'
|
|
485
|
+
size='icon-sm'
|
|
486
|
+
aria-label={label}
|
|
487
|
+
title={title ?? label}
|
|
488
|
+
className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
|
|
489
|
+
onClick={(e) => {
|
|
639
490
|
e.stopPropagation()
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
491
|
+
onClick()
|
|
492
|
+
}}>
|
|
493
|
+
{children}
|
|
494
|
+
</Button>
|
|
643
495
|
)
|
|
644
496
|
}
|
|
645
497
|
|
|
498
|
+
|
|
646
499
|
/** A multi-select facet. Empty = no filter, which is why the trigger reads the
|
|
647
500
|
* facet's name rather than "All": nothing is being excluded. */
|
|
648
501
|
/**
|