@workerdeck/ui 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
- package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
- package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
- package/build/format.d.mts +16 -5
- package/build/format.mjs +2 -3
- package/build/index.d.mts +288 -8
- package/build/index.mjs +620 -165
- package/build/index.mjs.map +1 -1
- package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
- package/build/status-BE-zg88x.mjs.map +1 -0
- package/build/workspace.d.mts +4 -1
- package/build/workspace.mjs +4 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +8 -6
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +191 -17
- package/src/components/agent/SessionPanel.tsx +177 -2
- package/src/components/agent/SessionSteps.tsx +233 -0
- package/src/components/agent/SessionWorkspace.tsx +4 -0
- package/src/components/agent/StatusBar.tsx +4 -2
- package/src/components/agent/SubagentStrip.tsx +134 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +212 -23
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +122 -23
- package/src/components/terminal/TerminalTranscript.tsx +154 -2
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +260 -0
- package/src/components/terminal/height.ts +73 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +116 -79
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +172 -26
- package/src/components/terminal/tool-run.ts +177 -0
- package/src/index.ts +20 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +85 -5
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
- package/build/format-DqR56Y8l.mjs.map +0 -1
- package/build/status-Ydzi7n6j.mjs +0 -143
- package/build/status-Ydzi7n6j.mjs.map +0 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ProjectIcon as ProjectIconSpec } from '@workerdeck/protocol'
|
|
2
|
+
import {
|
|
3
|
+
Anchor, Atom, Beaker, Bike, Binary, Blocks, Bot, Box, Brain, Briefcase, Brush, Bug,
|
|
4
|
+
Building2, Cable, Camera, Car, Cat, CircuitBoard, Cloud, Code, Coffee, Cog, Compass,
|
|
5
|
+
Container, Cpu, CreditCard, Database, Diamond, Dog, Feather, Film, Flame, FlaskConical,
|
|
6
|
+
Folder, Gamepad2, Gauge, Gem, Ghost, Gift, GitBranch, Globe, GraduationCap, Hammer,
|
|
7
|
+
Hexagon, House, Image, Key, Landmark, Laptop, Layers, Leaf, Library, Lightbulb, Lock,
|
|
8
|
+
Mail, Map, MessageCircle, Mic, Monitor, Moon, Mountain, Music, Network, Newspaper,
|
|
9
|
+
Notebook, Orbit, Package, Palette, PenTool, Phone, PiggyBank, Plane, Plug, Puzzle,
|
|
10
|
+
Radar, Radio, Receipt, Rocket, Ruler, Satellite, Scale, Scissors, Server, Shield, Ship,
|
|
11
|
+
ShoppingCart, Signal, Smartphone, Sparkles, Sprout, Square, Star, Sun, Table, Tag,
|
|
12
|
+
Target, Telescope, Terminal, TestTube, TreePine, Trophy, Truck, Umbrella, Users, Wallet,
|
|
13
|
+
Wand, Waves, Webhook, Wifi, Wrench, Zap,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import type { ComponentType } from 'react'
|
|
16
|
+
import { cn } from '../../lib/utils.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A project's icon — the render side of protocol's `ProjectIcon`, for a list
|
|
20
|
+
* row or a group header.
|
|
21
|
+
*
|
|
22
|
+
* **The glyph arm is a curated set, and that is the design rather than a
|
|
23
|
+
* shortcut.** The gateway validates a glyph name by *shape* only (lucide's
|
|
24
|
+
* lowercase-kebab convention) and explicitly declines to grow an icon catalog,
|
|
25
|
+
* so the contract already says a client must fall back on a name it does not
|
|
26
|
+
* know.
|
|
27
|
+
*
|
|
28
|
+
* The alternative was measured rather than argued, against the VS Code
|
|
29
|
+
* sidebar's own bundle: `sidebar.js` is **31 KB** with one glyph, **77 KB**
|
|
30
|
+
* with the 110 below (~418 bytes each), and **927 KB** with a
|
|
31
|
+
* `import * as` over lucide's ~1,600 — which is what it costs, because a
|
|
32
|
+
* namespace import defeats tree-shaking by construction. (`DynamicIcon` is the
|
|
33
|
+
* third option and worse in a different currency: ~1,600 chunk files shipped
|
|
34
|
+
* inside the `.vsix`.) Twelve times the bundle to render a name nobody has
|
|
35
|
+
* declared yet is not a trade worth making; 46 KB to cover the names people
|
|
36
|
+
* actually use is.
|
|
37
|
+
*
|
|
38
|
+
* The set is chosen to cover what people call a project; an unlisted-but-valid
|
|
39
|
+
* name draws {@link Folder}, which is what the row drew before this feature
|
|
40
|
+
* existed. **Grow it freely** — each addition is ~400 bytes and the fallback
|
|
41
|
+
* means a missing one is a shrug, not a hole.
|
|
42
|
+
*
|
|
43
|
+
* **The image arm draws nothing until its bytes arrive**, and takes them as a
|
|
44
|
+
* resolved `src` rather than fetching: the wire carries only an address
|
|
45
|
+
* (media type, size, content hash), and *who* fetches it differs per client —
|
|
46
|
+
* a VS Code webview cannot reach a gateway at all and must be handed a data
|
|
47
|
+
* URL by its extension host, while a browser can fetch and `createObjectURL`.
|
|
48
|
+
* A component that fetched would be wrong on one of them. Absent `src` renders
|
|
49
|
+
* nothing rather than a placeholder box: an icon is decoration beside a name
|
|
50
|
+
* that is already there, and a box that becomes a picture a beat later is more
|
|
51
|
+
* movement than the picture is worth.
|
|
52
|
+
*
|
|
53
|
+
* One thing an image icon cannot do, and a glyph can: **take the row's
|
|
54
|
+
* colour.** An `<img>`-embedded SVG is its own document, so `currentColor` does
|
|
55
|
+
* not reach it and its own `prefers-color-scheme` resolves against the *OS*
|
|
56
|
+
* rather than the host's theme (measured: it does resolve, which is the
|
|
57
|
+
* surprise — so a monochrome mark tuned for both schemes is right on a dark OS
|
|
58
|
+
* in a dark editor and wrong on a light OS in a dark one). A repo that wants a
|
|
59
|
+
* mark that always matches its row should declare a glyph; one that wants its
|
|
60
|
+
* brand should declare the image and accept that it is a picture.
|
|
61
|
+
*/
|
|
62
|
+
const GLYPHS: Record<string, ComponentType<{ className?: string }>> = {
|
|
63
|
+
anchor: Anchor, atom: Atom, beaker: Beaker, bike: Bike, binary: Binary, blocks: Blocks,
|
|
64
|
+
bot: Bot, box: Box, brain: Brain, briefcase: Briefcase, brush: Brush, bug: Bug,
|
|
65
|
+
'building-2': Building2, cable: Cable, camera: Camera, car: Car, cat: Cat,
|
|
66
|
+
'circuit-board': CircuitBoard, cloud: Cloud, code: Code, coffee: Coffee, cog: Cog,
|
|
67
|
+
compass: Compass, container: Container, cpu: Cpu, 'credit-card': CreditCard,
|
|
68
|
+
database: Database, diamond: Diamond, dog: Dog, feather: Feather, film: Film,
|
|
69
|
+
flame: Flame, 'flask-conical': FlaskConical, folder: Folder, 'gamepad-2': Gamepad2,
|
|
70
|
+
gauge: Gauge, gem: Gem, ghost: Ghost, gift: Gift, 'git-branch': GitBranch, globe: Globe,
|
|
71
|
+
'graduation-cap': GraduationCap, hammer: Hammer, hexagon: Hexagon, house: House,
|
|
72
|
+
image: Image, key: Key, landmark: Landmark, laptop: Laptop, layers: Layers, leaf: Leaf,
|
|
73
|
+
library: Library, lightbulb: Lightbulb, lock: Lock, mail: Mail, map: Map,
|
|
74
|
+
'message-circle': MessageCircle, mic: Mic, monitor: Monitor, moon: Moon,
|
|
75
|
+
mountain: Mountain, music: Music, network: Network, newspaper: Newspaper,
|
|
76
|
+
notebook: Notebook, orbit: Orbit, package: Package, palette: Palette, 'pen-tool': PenTool,
|
|
77
|
+
phone: Phone, 'piggy-bank': PiggyBank, plane: Plane, plug: Plug, puzzle: Puzzle,
|
|
78
|
+
radar: Radar, radio: Radio, receipt: Receipt, rocket: Rocket, ruler: Ruler,
|
|
79
|
+
satellite: Satellite, scale: Scale, scissors: Scissors, server: Server, shield: Shield,
|
|
80
|
+
ship: Ship, 'shopping-cart': ShoppingCart, signal: Signal, smartphone: Smartphone,
|
|
81
|
+
sparkles: Sparkles, sprout: Sprout, square: Square, star: Star, sun: Sun, table: Table,
|
|
82
|
+
tag: Tag, target: Target, telescope: Telescope, terminal: Terminal, 'test-tube': TestTube,
|
|
83
|
+
'tree-pine': TreePine, trophy: Trophy, truck: Truck, umbrella: Umbrella, users: Users,
|
|
84
|
+
wallet: Wallet, wand: Wand, waves: Waves, webhook: Webhook, wifi: Wifi, wrench: Wrench,
|
|
85
|
+
zap: Zap,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ProjectIcon({
|
|
89
|
+
icon,
|
|
90
|
+
src,
|
|
91
|
+
name,
|
|
92
|
+
className,
|
|
93
|
+
}: {
|
|
94
|
+
icon: ProjectIconSpec | undefined
|
|
95
|
+
/** Resolved bytes for the `image` arm — an object URL or a data URL. The
|
|
96
|
+
* caller fetches (see the note above); absent draws nothing. */
|
|
97
|
+
src?: string
|
|
98
|
+
/** The project's name, for the alt text. */
|
|
99
|
+
name?: string
|
|
100
|
+
className?: string
|
|
101
|
+
}) {
|
|
102
|
+
if (!icon) return null
|
|
103
|
+
if (icon.type === 'image') {
|
|
104
|
+
if (!src) return null
|
|
105
|
+
return (
|
|
106
|
+
<img
|
|
107
|
+
src={src}
|
|
108
|
+
alt=''
|
|
109
|
+
aria-hidden
|
|
110
|
+
title={name}
|
|
111
|
+
// `object-contain` because a declared icon is whatever aspect the repo
|
|
112
|
+
// checked in, and a squashed logo reads worse than a letterboxed one.
|
|
113
|
+
className={cn('inline-block size-3 shrink-0 object-contain', className)}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
const Glyph = GLYPHS[icon.name] ?? Folder
|
|
118
|
+
return <Glyph className={cn('inline-block size-3 shrink-0', className)} />
|
|
119
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
import {
|
|
3
4
|
BellRing,
|
|
4
5
|
CircleAlert,
|
|
@@ -20,6 +21,9 @@ import {
|
|
|
20
21
|
filterRows,
|
|
21
22
|
groupRows,
|
|
22
23
|
hasFacetFilter,
|
|
24
|
+
projectLabel,
|
|
25
|
+
projectSubpath,
|
|
26
|
+
projectsOf,
|
|
23
27
|
sessionLabel,
|
|
24
28
|
subsetSummary,
|
|
25
29
|
} from '@workerdeck/protocol'
|
|
@@ -36,6 +40,10 @@ import { Empty } from '../ui/Empty.tsx'
|
|
|
36
40
|
import { Input } from '../ui/Input.tsx'
|
|
37
41
|
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
38
42
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
43
|
+
import { ContextRing } from './ContextRing.tsx'
|
|
44
|
+
import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
|
|
45
|
+
import { ProjectIcon } from './ProjectIcon.tsx'
|
|
46
|
+
import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
|
|
39
47
|
import { cn } from '../../lib/utils.ts'
|
|
40
48
|
import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
|
|
41
49
|
|
|
@@ -74,6 +82,10 @@ export interface SessionBrowserProps {
|
|
|
74
82
|
* double-click would have already left the page.
|
|
75
83
|
*/
|
|
76
84
|
onRename?: (row: SessionRow, title: string) => void
|
|
85
|
+
/** Open a session *at* one of its sub-agents, when the host can scroll a
|
|
86
|
+
* transcript to a `Task` row. Absent, the sub-agent list still expands and a
|
|
87
|
+
* step just opens its session — see `SessionRowItemProps`. */
|
|
88
|
+
onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
|
|
77
89
|
/** Rendered when nothing at all exists (as opposed to nothing matching). */
|
|
78
90
|
emptyState?: React.ReactNode
|
|
79
91
|
/**
|
|
@@ -88,6 +100,15 @@ export interface SessionBrowserProps {
|
|
|
88
100
|
* filtered by a control you can't currently see still says so.
|
|
89
101
|
*/
|
|
90
102
|
showControls?: boolean
|
|
103
|
+
/**
|
|
104
|
+
* Resolved project-icon bytes by content hash — `useProjectIcons`' output.
|
|
105
|
+
*
|
|
106
|
+
* Passed in rather than fetched here for the reason `ProjectIcon` states: the
|
|
107
|
+
* wire carries an *address*, and who can fetch it differs per client. Absent,
|
|
108
|
+
* or a hash not in it yet, simply draws no picture; the project's name is
|
|
109
|
+
* already there.
|
|
110
|
+
*/
|
|
111
|
+
projectIcons?: Record<string, string>
|
|
91
112
|
className?: string
|
|
92
113
|
}
|
|
93
114
|
|
|
@@ -123,8 +144,10 @@ export function SessionBrowser({
|
|
|
123
144
|
onSelect,
|
|
124
145
|
onDelete,
|
|
125
146
|
onRename,
|
|
147
|
+
onSelectSubagent,
|
|
126
148
|
emptyState,
|
|
127
149
|
showControls = true,
|
|
150
|
+
projectIcons,
|
|
128
151
|
className,
|
|
129
152
|
}: SessionBrowserProps) {
|
|
130
153
|
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
@@ -133,6 +156,7 @@ export function SessionBrowser({
|
|
|
133
156
|
// Derived, not enumerated: a new engine or a new gateway needs no change here,
|
|
134
157
|
// and a facet with one possible value is not a choice worth showing.
|
|
135
158
|
const adapters = useMemo(() => adaptersOf(rows), [rows])
|
|
159
|
+
const projects = useMemo(() => projectsOf(rows), [rows])
|
|
136
160
|
const gateways = useMemo(() => {
|
|
137
161
|
const seen = new Map<string, string>()
|
|
138
162
|
for (const row of rows) seen.set(row.hostId, row.hostName)
|
|
@@ -187,6 +211,16 @@ export function SessionBrowser({
|
|
|
187
211
|
/>
|
|
188
212
|
</FilterRow>
|
|
189
213
|
) : null}
|
|
214
|
+
{projects.length > 1 ? (
|
|
215
|
+
<FilterRow label='Project'>
|
|
216
|
+
<FacetSelect
|
|
217
|
+
label='Project'
|
|
218
|
+
value={config.projects ?? []}
|
|
219
|
+
options={projects.map((p) => ({ value: p.key, label: p.label }))}
|
|
220
|
+
onChange={(next) => set({ projects: next })}
|
|
221
|
+
/>
|
|
222
|
+
</FilterRow>
|
|
223
|
+
) : null}
|
|
190
224
|
<FilterRow label='Group'>
|
|
191
225
|
<OneOfSelect
|
|
192
226
|
label='Group'
|
|
@@ -195,6 +229,7 @@ export function SessionBrowser({
|
|
|
195
229
|
{ value: 'none', label: 'No grouping' },
|
|
196
230
|
{ value: 'state', label: 'By state' },
|
|
197
231
|
{ value: 'adapter', label: 'By engine' },
|
|
232
|
+
...(projects.length > 1 ? [{ value: 'project' as const, label: 'By project' }] : []),
|
|
198
233
|
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
|
|
199
234
|
]}
|
|
200
235
|
onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
|
|
@@ -208,6 +243,7 @@ export function SessionBrowser({
|
|
|
208
243
|
{ value: 'recent', label: 'Recent' },
|
|
209
244
|
{ value: 'name', label: 'Name' },
|
|
210
245
|
{ value: 'state', label: 'State' },
|
|
246
|
+
...(projects.length > 1 ? [{ value: 'project' as const, label: 'Project' }] : []),
|
|
211
247
|
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
|
|
212
248
|
]}
|
|
213
249
|
onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
|
|
@@ -253,7 +289,16 @@ export function SessionBrowser({
|
|
|
253
289
|
{groups.map((group) => (
|
|
254
290
|
<div key={group.key} className='flex flex-col gap-1'>
|
|
255
291
|
{config.groupBy !== 'none' && group.label ? (
|
|
256
|
-
<div className='flex items-
|
|
292
|
+
<div className='flex items-center gap-2 px-3 text-label font-medium text-fg-4'>
|
|
293
|
+
{/* Only the project facet has a mark of its own, and a group
|
|
294
|
+
IS one project root, so the first row is a fair source. */}
|
|
295
|
+
{config.groupBy === 'project' ? (
|
|
296
|
+
<ProjectIcon
|
|
297
|
+
icon={group.rows[0]?.info.project?.icon}
|
|
298
|
+
src={iconSrcOf(group.rows[0], projectIcons)}
|
|
299
|
+
name={group.label}
|
|
300
|
+
/>
|
|
301
|
+
) : null}
|
|
257
302
|
<span className='uppercase tracking-wide'>{group.label}</span>
|
|
258
303
|
<span className='text-fg-4/70'>{group.rows.length}</span>
|
|
259
304
|
</div>
|
|
@@ -263,10 +308,13 @@ export function SessionBrowser({
|
|
|
263
308
|
key={`${row.hostId}:${row.info.id}`}
|
|
264
309
|
row={row}
|
|
265
310
|
active={row.info.id === activeId}
|
|
266
|
-
showGateway={gateways.length > 1}
|
|
311
|
+
showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
|
|
312
|
+
showProject={config.groupBy !== 'project'}
|
|
313
|
+
projectIcons={projectIcons}
|
|
267
314
|
onSelect={onSelect}
|
|
268
315
|
onDelete={onDelete}
|
|
269
316
|
onRename={onRename}
|
|
317
|
+
onSelectSubagent={onSelectSubagent}
|
|
270
318
|
/>
|
|
271
319
|
))}
|
|
272
320
|
</div>
|
|
@@ -277,36 +325,86 @@ export function SessionBrowser({
|
|
|
277
325
|
)
|
|
278
326
|
}
|
|
279
327
|
|
|
328
|
+
/** The bytes for a row's project icon, if it has an image one and the caller has
|
|
329
|
+
* fetched it yet. Shared by the row and its group header so the two cannot draw
|
|
330
|
+
* different pictures for one project. */
|
|
331
|
+
function iconSrcOf(
|
|
332
|
+
row: SessionRow | undefined,
|
|
333
|
+
icons: Record<string, string> | undefined,
|
|
334
|
+
): string | undefined {
|
|
335
|
+
const icon = row?.info.project?.icon
|
|
336
|
+
return icon?.type === 'image' ? icons?.[icon.hash] : undefined
|
|
337
|
+
}
|
|
338
|
+
|
|
280
339
|
interface SessionRowItemProps {
|
|
281
340
|
row: SessionRow
|
|
282
341
|
active?: boolean
|
|
283
342
|
showGateway?: boolean
|
|
343
|
+
/** False when the list is already grouped by project — the header has said the
|
|
344
|
+
* name, so the row must not spend its metadata line repeating it. What takes
|
|
345
|
+
* the slot is the *sub-path inside the project* (`projectSubpath`), which is
|
|
346
|
+
* the one thing the header cannot say and the only thing that tells two
|
|
347
|
+
* sessions in the same repo apart; a session sitting at the project root has
|
|
348
|
+
* nothing to add and the slot disappears entirely. The rule `showGateway`
|
|
349
|
+
* follows one facet over. */
|
|
350
|
+
showProject?: boolean
|
|
351
|
+
projectIcons?: Record<string, string>
|
|
284
352
|
onSelect?: (row: SessionRow) => void
|
|
285
353
|
onDelete?: (row: SessionRow) => void
|
|
286
354
|
onRename?: (row: SessionRow, title: string) => void
|
|
355
|
+
/** Open one of a session's sub-agents — which now means handing the session
|
|
356
|
+
* panel over to that agent's own work (`SessionPanel.openSubagent`), and meant
|
|
357
|
+
* "scroll to its `Task` row" before that surface existed. Both are honest; a
|
|
358
|
+
* host picks what it can draw. Optional, and its absence is not a missing
|
|
359
|
+
* feature: without it a step just opens the session, which is all a host with
|
|
360
|
+
* neither can offer. Only *agent* steps ever call it — see `Step.kind`. */
|
|
361
|
+
onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
|
|
287
362
|
}
|
|
288
363
|
|
|
289
364
|
function SessionRowItem({
|
|
290
365
|
row,
|
|
291
366
|
active,
|
|
292
367
|
showGateway,
|
|
368
|
+
showProject = true,
|
|
369
|
+
projectIcons,
|
|
293
370
|
onSelect,
|
|
294
371
|
onDelete,
|
|
295
372
|
onRename,
|
|
373
|
+
onSelectSubagent,
|
|
296
374
|
}: SessionRowItemProps) {
|
|
297
375
|
const { info } = row
|
|
298
376
|
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)
|
|
299
381
|
|
|
300
382
|
// What it is and what it has spent, in one line — the same set the extension
|
|
301
383
|
// shows, joined the same way, so the two lists read as one product.
|
|
302
|
-
|
|
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.
|
|
303
394
|
const details = [
|
|
395
|
+
project,
|
|
304
396
|
showGateway ? row.hostName : undefined,
|
|
305
|
-
friendlyModel(info.model),
|
|
306
|
-
folder,
|
|
307
397
|
info.profile ? `@${info.profile}` : undefined,
|
|
308
398
|
formatCost(info.totalCostUsd),
|
|
309
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)
|
|
310
408
|
|
|
311
409
|
return (
|
|
312
410
|
<div
|
|
@@ -324,9 +422,20 @@ function SessionRowItem({
|
|
|
324
422
|
'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
|
|
325
423
|
rowShapeClass(active === true),
|
|
326
424
|
)}>
|
|
327
|
-
{/* Line one: what you scan the list by
|
|
328
|
-
|
|
425
|
+
{/* Line one: how it is doing, then what you scan the list by, then how
|
|
426
|
+
long since it moved.
|
|
427
|
+
|
|
428
|
+
State leads. It used to sit last, on the argument that the title is
|
|
429
|
+
what you read and the state is what you glance at — but a glance wants
|
|
430
|
+
a *column*, and a trailing glyph has no fixed x: it lands wherever the
|
|
431
|
+
age and the ring leave it. Leading, every row's state stacks into one
|
|
432
|
+
scannable strip, and the mark on line two lands under it in the same
|
|
433
|
+
gutter. This is the VS Code sidebar's shape, and all three clients are
|
|
434
|
+
on it now. */}
|
|
329
435
|
<div className='flex items-center gap-1.5'>
|
|
436
|
+
<Gutter>
|
|
437
|
+
<SessionStatusIcon row={row} />
|
|
438
|
+
</Gutter>
|
|
330
439
|
{/* The editor replaces the link rather than sitting inside it — an
|
|
331
440
|
input nested in a button is invalid, and disabling the button to
|
|
332
441
|
protect the edit disables the field with it. */}
|
|
@@ -361,18 +470,59 @@ function SessionRowItem({
|
|
|
361
470
|
<span className='shrink-0 text-label text-fg-4'>
|
|
362
471
|
{formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
|
|
363
472
|
</span>
|
|
364
|
-
<
|
|
473
|
+
<ContextRing usage={info.contextUsage} />
|
|
365
474
|
</div>
|
|
366
475
|
|
|
367
|
-
{/* Line two: what it is
|
|
368
|
-
|
|
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. */}
|
|
369
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>
|
|
370
490
|
<button
|
|
371
491
|
type='button'
|
|
372
492
|
tabIndex={-1}
|
|
373
493
|
className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
|
|
374
|
-
{
|
|
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
|
+
)}
|
|
375
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}
|
|
376
526
|
{onRename && !editing ? (
|
|
377
527
|
<Button
|
|
378
528
|
variant='ghost'
|
|
@@ -400,22 +550,46 @@ function SessionRowItem({
|
|
|
400
550
|
</Button>
|
|
401
551
|
) : null}
|
|
402
552
|
</div>
|
|
553
|
+
{expanded ? steps.map((step) => <StepRow key={step.key} step={step} onSelect={step.onSelect} />) : null}
|
|
403
554
|
</div>
|
|
404
555
|
)
|
|
405
556
|
}
|
|
406
557
|
|
|
407
558
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
559
|
+
* The one 14px cell both lines hang their glyph in.
|
|
560
|
+
*
|
|
561
|
+
* A shared column rather than a leading character: the state and the engine mark
|
|
562
|
+
* are different widths and a bare glyph would let the two text columns start at
|
|
563
|
+
* different x. Centring inside a fixed cell is what makes them agree —
|
|
564
|
+
* `packages/ui`'s terminal-gutter argument at row scale.
|
|
565
|
+
*/
|
|
566
|
+
function Gutter({ children }: { children: ReactNode }) {
|
|
567
|
+
return <span className='flex w-3.5 shrink-0 items-center justify-center'>{children}</span>
|
|
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.
|
|
412
586
|
*/
|
|
413
587
|
export function SessionStatusIcon({ row }: { row: SessionRow }) {
|
|
414
588
|
const { info } = row
|
|
415
|
-
if (
|
|
589
|
+
if (row.state === 'attention') {
|
|
416
590
|
return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
|
|
417
591
|
}
|
|
418
|
-
if (
|
|
592
|
+
if (row.state === 'working') {
|
|
419
593
|
return <Spinner className='size-3.5 shrink-0 text-info' />
|
|
420
594
|
}
|
|
421
595
|
switch (info.status) {
|