@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.
Files changed (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. 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-baseline gap-2 px-3 text-label font-medium text-fg-4'>
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
- const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
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 on the left, how it is doing on the
328
- right, state last. */}
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
- <SessionStatusIcon row={row} />
473
+ <ContextRing usage={info.contextUsage} />
365
474
  </div>
366
475
 
367
- {/* Line two: what it is, with the actions at the far right away from the
368
- state icon, and away from the title you are actually reading. */}
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
- {details.join(' · ')}
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
- * State as one glyph on the right edge a ringing bell when it wants a human, a
409
- * spinner while it works, a moon when it is only sleeping. Replaces the text
410
- * badge: in a sidebar the word costs more room than it earns, and the states
411
- * that matter are the two you can recognise without reading.
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 (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') {
589
+ if (row.state === 'attention') {
416
590
  return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
417
591
  }
418
- if (info.status === 'running' || info.status === 'starting') {
592
+ if (row.state === 'working') {
419
593
  return <Spinner className='size-3.5 shrink-0 text-info' />
420
594
  }
421
595
  switch (info.status) {