@workerdeck/ui 0.17.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 (40) hide show
  1. package/build/{SessionPanel-CnNYEX80.d.mts → SessionPanel-CnU_IJ3-.d.mts} +24 -9
  2. package/build/{SessionPanel-DMPhsNlW.mjs → SessionPanel-DPx8Iz8a.mjs} +439 -74
  3. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  4. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  5. package/build/format.d.mts +16 -5
  6. package/build/format.mjs +2 -3
  7. package/build/index.d.mts +237 -8
  8. package/build/index.mjs +399 -167
  9. package/build/index.mjs.map +1 -1
  10. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  11. package/build/status-BE-zg88x.mjs.map +1 -0
  12. package/build/workspace.d.mts +4 -1
  13. package/build/workspace.mjs +4 -3
  14. package/build/workspace.mjs.map +1 -1
  15. package/package.json +4 -4
  16. package/src/components/agent/ContextRing.tsx +41 -0
  17. package/src/components/agent/EngineIcon.tsx +40 -0
  18. package/src/components/agent/SessionBrowser.tsx +115 -22
  19. package/src/components/agent/SessionPanel.tsx +153 -3
  20. package/src/components/agent/SessionSteps.tsx +233 -0
  21. package/src/components/agent/SessionWorkspace.tsx +4 -0
  22. package/src/components/agent/StatusBar.tsx +4 -2
  23. package/src/components/agent/SubagentStrip.tsx +134 -0
  24. package/src/components/agent/Transcript.tsx +138 -19
  25. package/src/components/agent/transcript-rows.ts +9 -1
  26. package/src/components/terminal/TerminalTranscript.tsx +77 -3
  27. package/src/components/terminal/affordances.tsx +34 -0
  28. package/src/components/terminal/blocks.ts +28 -0
  29. package/src/components/terminal/height.ts +24 -0
  30. package/src/components/terminal/items.tsx +1 -1
  31. package/src/components/terminal/scrubber.tsx +28 -3
  32. package/src/components/terminal/tool-run.ts +49 -5
  33. package/src/index.ts +19 -1
  34. package/src/lib/status.ts +16 -3
  35. package/src/styles/terminal.css +10 -0
  36. package/src/styles/theme.css +42 -0
  37. package/build/SessionPanel-DMPhsNlW.mjs.map +0 -1
  38. package/build/format-DqR56Y8l.mjs.map +0 -1
  39. package/build/status-Ydzi7n6j.mjs +0 -143
  40. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -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,
@@ -21,6 +22,7 @@ import {
21
22
  groupRows,
22
23
  hasFacetFilter,
23
24
  projectLabel,
25
+ projectSubpath,
24
26
  projectsOf,
25
27
  sessionLabel,
26
28
  subsetSummary,
@@ -38,7 +40,10 @@ import { Empty } from '../ui/Empty.tsx'
38
40
  import { Input } from '../ui/Input.tsx'
39
41
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
40
42
  import { Spinner } from '../ui/Spinner.tsx'
43
+ import { ContextRing } from './ContextRing.tsx'
44
+ import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
41
45
  import { ProjectIcon } from './ProjectIcon.tsx'
46
+ import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
42
47
  import { cn } from '../../lib/utils.ts'
43
48
  import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
44
49
 
@@ -77,6 +82,10 @@ export interface SessionBrowserProps {
77
82
  * double-click would have already left the page.
78
83
  */
79
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
80
89
  /** Rendered when nothing at all exists (as opposed to nothing matching). */
81
90
  emptyState?: React.ReactNode
82
91
  /**
@@ -135,6 +144,7 @@ export function SessionBrowser({
135
144
  onSelect,
136
145
  onDelete,
137
146
  onRename,
147
+ onSelectSubagent,
138
148
  emptyState,
139
149
  showControls = true,
140
150
  projectIcons,
@@ -298,12 +308,13 @@ export function SessionBrowser({
298
308
  key={`${row.hostId}:${row.info.id}`}
299
309
  row={row}
300
310
  active={row.info.id === activeId}
301
- showGateway={gateways.length > 1}
311
+ showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
302
312
  showProject={config.groupBy !== 'project'}
303
313
  projectIcons={projectIcons}
304
314
  onSelect={onSelect}
305
315
  onDelete={onDelete}
306
316
  onRename={onRename}
317
+ onSelectSubagent={onSelectSubagent}
307
318
  />
308
319
  ))}
309
320
  </div>
@@ -329,15 +340,25 @@ interface SessionRowItemProps {
329
340
  row: SessionRow
330
341
  active?: boolean
331
342
  showGateway?: boolean
332
- /** False when the list is already grouped by project — the header has said
333
- * the name, so the slot goes back to the cwd's basename, which inside a
334
- * project group is the one thing the header cannot say. The rule `showGateway`
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`
335
349
  * follows one facet over. */
336
350
  showProject?: boolean
337
351
  projectIcons?: Record<string, string>
338
352
  onSelect?: (row: SessionRow) => void
339
353
  onDelete?: (row: SessionRow) => void
340
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
341
362
  }
342
363
 
343
364
  function SessionRowItem({
@@ -349,30 +370,41 @@ function SessionRowItem({
349
370
  onSelect,
350
371
  onDelete,
351
372
  onRename,
373
+ onSelectSubagent,
352
374
  }: SessionRowItemProps) {
353
375
  const { info } = row
354
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)
355
381
 
356
382
  // What it is and what it has spent, in one line — the same set the extension
357
383
  // shows, joined the same way, so the two lists read as one product.
358
- const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
359
384
  // protocol's own spelling, so this row, the group header above it and the
360
- // project facet cannot disagree about what a project is called. It falls back
361
- // to exactly the basename this line drew before the feature existed.
362
- const project = showProject ? projectLabel(row) : folder
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)
363
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.
364
394
  const details = [
365
- showGateway ? row.hostName : undefined,
366
- friendlyModel(info.model),
367
395
  project,
396
+ showGateway ? row.hostName : undefined,
368
397
  info.profile ? `@${info.profile}` : undefined,
369
398
  formatCost(info.totalCostUsd),
370
399
  ].filter(Boolean)
400
+ const steps = sessionSteps(info, (toolUseId) =>
401
+ onSelectSubagent ? onSelectSubagent(row, toolUseId) : onSelect?.(row),
402
+ )
371
403
  // Where the icon goes: immediately before the project's own name, wherever
372
404
  // that landed in the joined line. Split rather than interleaved as nodes,
373
405
  // because everything here is one truncating mono run and a flex of pieces
374
406
  // would each shrink a little and leave several half-words.
375
- const cut = details.indexOf(project)
407
+ const cut = project === undefined ? -1 : details.indexOf(project)
376
408
 
377
409
  return (
378
410
  <div
@@ -390,9 +422,20 @@ function SessionRowItem({
390
422
  'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
391
423
  rowShapeClass(active === true),
392
424
  )}>
393
- {/* Line one: what you scan the list by on the left, how it is doing on the
394
- 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. */}
395
435
  <div className='flex items-center gap-1.5'>
436
+ <Gutter>
437
+ <SessionStatusIcon row={row} />
438
+ </Gutter>
396
439
  {/* The editor replaces the link rather than sitting inside it — an
397
440
  input nested in a button is invalid, and disabling the button to
398
441
  protect the edit disables the field with it. */}
@@ -427,16 +470,33 @@ function SessionRowItem({
427
470
  <span className='shrink-0 text-label text-fg-4'>
428
471
  {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
429
472
  </span>
430
- <SessionStatusIcon row={row} />
473
+ <ContextRing usage={info.contextUsage} />
431
474
  </div>
432
475
 
433
- {/* Line two: what it is, with the actions at the far right away from the
434
- 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. */}
435
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>
436
490
  <button
437
491
  type='button'
438
492
  tabIndex={-1}
439
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 ? ' · ' : ''}
440
500
  {cut < 0 ? (
441
501
  details.join(' · ')
442
502
  ) : (
@@ -454,6 +514,15 @@ function SessionRowItem({
454
514
  </>
455
515
  )}
456
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}
457
526
  {onRename && !editing ? (
458
527
  <Button
459
528
  variant='ghost'
@@ -481,22 +550,46 @@ function SessionRowItem({
481
550
  </Button>
482
551
  ) : null}
483
552
  </div>
553
+ {expanded ? steps.map((step) => <StepRow key={step.key} step={step} onSelect={step.onSelect} />) : null}
484
554
  </div>
485
555
  )
486
556
  }
487
557
 
488
558
  /**
489
- * State as one glyph on the right edge a ringing bell when it wants a human, a
490
- * spinner while it works, a moon when it is only sleeping. Replaces the text
491
- * badge: in a sidebar the word costs more room than it earns, and the states
492
- * 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.
493
586
  */
494
587
  export function SessionStatusIcon({ row }: { row: SessionRow }) {
495
588
  const { info } = row
496
- if (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') {
589
+ if (row.state === 'attention') {
497
590
  return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
498
591
  }
499
- if (info.status === 'running' || info.status === 'starting') {
592
+ if (row.state === 'working') {
500
593
  return <Spinner className='size-3.5 shrink-0 text-info' />
501
594
  }
502
595
  switch (info.status) {
@@ -12,6 +12,7 @@ import {
12
12
  PROTOCOL_VERSION,
13
13
  mergeUsage,
14
14
  orderUsageWindows,
15
+ subagentLabel,
15
16
  usageInfos,
16
17
  type ModelOption,
17
18
  type PermissionMode,
@@ -54,6 +55,8 @@ import {
54
55
  type PermissionModeChoice,
55
56
  } from './PermissionModeSelect.tsx'
56
57
  import { PermissionPrompt } from './PermissionPrompt.tsx'
58
+ import { SubagentStrip } from './SubagentStrip.tsx'
59
+ import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
57
60
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
58
61
  import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
59
62
  import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
@@ -237,6 +240,25 @@ export interface SessionPanelProps {
237
240
  * attach to find out where it is.
238
241
  */
239
242
  reveal?: { toolUseId: string; nonce: number }
243
+ /**
244
+ * Open a **sub-agent takeover**: the panel body becomes that agent's own work,
245
+ * with a way back. Bump `nonce` to ask again for the same one.
246
+ *
247
+ * A *request*, not a controlled value — the panel owns which agent is open,
248
+ * exactly as it owns `panel`. Dismissal has to work with zero host wiring
249
+ * (Back and Escape are the panel's own affordances), and the two hosts in
250
+ * scope reach this across a postMessage bridge where a controlled prop would
251
+ * need a live closure at the far end. Same shape and same reason as
252
+ * {@link SessionPanelProps.reveal}.
253
+ *
254
+ * Hosts must clear their request on a session switch: a stale one replayed at
255
+ * remount would open a frame the new transcript cannot answer.
256
+ *
257
+ * Claude-only in practice, and gated by data rather than by a flag — codex and
258
+ * provider sessions have no `parentToolUseId`, so they grow no task blocks and
259
+ * no sub-agent rows, and nothing can raise this.
260
+ */
261
+ openSubagent?: { toolUseId: string; nonce: number }
240
262
  /**
241
263
  * Terminal theme only: hold the prompt of the turn you are reading at the top
242
264
  * of the transcript, as the Claude Code CLI does. The **real row** is pinned
@@ -452,6 +474,7 @@ export function SessionPanel({
452
474
  scrubber = false,
453
475
  scrubberMarks,
454
476
  reveal,
477
+ openSubagent,
455
478
  stickyPrompt = false,
456
479
  controlsSurface = 'internal',
457
480
  onControls,
@@ -475,6 +498,29 @@ export function SessionPanel({
475
498
  // is not an error channel.
476
499
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
477
500
  const [panel, setPanel] = useState<Panel | undefined>()
501
+ /**
502
+ * The sub-agent takeover: which `Task` call the body is currently showing, or
503
+ * undefined for the conversation.
504
+ *
505
+ * Deliberately **not** a `Panel` member. Those route outward under
506
+ * `panelSurface: 'external'` so a host can draw them natively — and a host
507
+ * cannot draw this one: it is a transcript, and the transcript is in here. It
508
+ * is a mode of the conversation surface, not a screen over it.
509
+ */
510
+ const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
511
+ /**
512
+ * Where to land on the way back — the row of the Task you entered from,
513
+ * asked for through the ordinary reveal seam.
514
+ *
515
+ * The alternative was keeping the main transcript mounted-but-hidden to
516
+ * preserve its scroll offset, which costs two live virtualizers and an
517
+ * absolute-position layout on a panel whose height just changed (the composer
518
+ * came back). Re-revealing is both cheaper and more honest: you return to the
519
+ * row you left from rather than to wherever you happened to be scrolled.
520
+ */
521
+ const [returnReveal, setReturnReveal] = useState<
522
+ { toolUseId: string; nonce: number } | undefined
523
+ >(undefined)
478
524
  const {
479
525
  state,
480
526
  connection,
@@ -495,6 +541,53 @@ export function SessionPanel({
495
541
  // Callers are told to remount on a session switch, but a changed prop must not leave
496
542
  // the previous session's failure on screen.
497
543
  useEffect(() => setProtocolError(undefined), [sessionId])
544
+ // Belt to the remount contract, for the same reason: a takeover left standing
545
+ // across a switch would frame one session's Task id against another's items.
546
+ useEffect(() => {
547
+ setSubagentId(undefined)
548
+ setReturnReveal(undefined)
549
+ }, [sessionId])
550
+
551
+ // The host asking. Keyed on the nonce, so asking twice for the same agent
552
+ // works — and so a request that arrives while another frame is open swaps it
553
+ // in place rather than being ignored.
554
+ const openSubagentNonce = openSubagent?.nonce
555
+ const openSubagentId = openSubagent?.toolUseId
556
+ useEffect(() => {
557
+ if (openSubagentId === undefined) return
558
+ setSubagentId(openSubagentId)
559
+ // eslint-disable-next-line react-hooks/exhaustive-deps
560
+ }, [openSubagentNonce])
561
+
562
+ // A *reveal* asked for while framed means the reader wants the conversation:
563
+ // latest intent wins. Leaving the frame up and scrolling something invisible
564
+ // underneath it would be the worst of both.
565
+ const revealNonce = reveal?.nonce
566
+ useEffect(() => {
567
+ if (revealNonce === undefined) return
568
+ setSubagentId(undefined)
569
+ // eslint-disable-next-line react-hooks/exhaustive-deps
570
+ }, [revealNonce])
571
+
572
+ const leaveSubagent = useCallback(() => {
573
+ setSubagentId((current) => {
574
+ if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
575
+ return undefined
576
+ })
577
+ }, [])
578
+
579
+ // Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
580
+ // keeps a dialog's own Escape (and the composer's) ahead of it: this is the
581
+ // outermost thing Escape can mean here, so it goes last.
582
+ useEffect(() => {
583
+ if (subagentId === undefined) return
584
+ const onKey = (event: KeyboardEvent) => {
585
+ if (event.key !== 'Escape' || event.defaultPrevented) return
586
+ leaveSubagent()
587
+ }
588
+ window.addEventListener('keydown', onKey)
589
+ return () => window.removeEventListener('keydown', onKey)
590
+ }, [subagentId, leaveSubagent])
498
591
 
499
592
  // Catch-up is entered once, from the watermark the embedder handed over, and
500
593
  // left when dismissed or when the user sends anything (they are plainly
@@ -535,6 +628,29 @@ export function SessionPanel({
535
628
  // which for them never comes.
536
629
  useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
537
630
  const terminal = transcriptVariant === 'terminal'
631
+
632
+ // What the strip reads. The frame's items and its spawning call both come from
633
+ // the transcript, which is the only complete source: `SessionInfo.subagents`
634
+ // keeps eight settled records and is explicitly not a session's Task history.
635
+ const subagentFrameItems = useMemo(
636
+ () => (subagentId === undefined ? [] : subagentItems(state.items, subagentId)),
637
+ [state.items, subagentId],
638
+ )
639
+ const subagentTask = useMemo(
640
+ () =>
641
+ subagentId === undefined
642
+ ? undefined
643
+ : state.items.find(
644
+ (item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId,
645
+ ),
646
+ [state.items, subagentId],
647
+ )
648
+ // The one thing the rollup is allowed to do: name an agent whose `Task` call
649
+ // the transcript does not have. A label is not content.
650
+ const subagentFallbackLabel = useMemo(() => {
651
+ const record = state.session?.subagents?.find((sub) => sub.toolUseId === subagentId)
652
+ return record ? subagentLabel(record) : 'Sub-agent'
653
+ }, [state.session, subagentId])
538
654
  const capabilities = state.capabilities
539
655
 
540
656
  // Plan usage as the *gateway* knows it, merged over this session's own
@@ -850,7 +966,27 @@ export function SessionPanel({
850
966
  {protocolError}
851
967
  </Notice>
852
968
  ) : null}
969
+ {/* The takeover's one line: who is on screen, and the way back. Above
970
+ the transcript because it is a frame around it, not a row in it —
971
+ and because Back must be reachable without scrolling a stream that
972
+ is still growing. */}
973
+ {subagentId !== undefined ? (
974
+ <SubagentStrip
975
+ task={subagentTask}
976
+ items={subagentFrameItems}
977
+ label={subagentFallbackLabel}
978
+ onBack={leaveSubagent}
979
+ terminal={terminal}
980
+ fontSize={terminalMetrics?.fontSize}
981
+ lineHeight={terminalMetrics?.lineHeight}
982
+ />
983
+ ) : null}
853
984
  <Transcript
985
+ /* A remount, so the frame opens pinned to its own bottom with a fresh
986
+ virtualizer and height epoch — which is the right landing for both
987
+ cases: the live tail of a running agent, and the final report of a
988
+ settled one. */
989
+ key={subagentId ?? 'session'}
854
990
  state={state}
855
991
  fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
856
992
  attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
@@ -870,7 +1006,11 @@ export function SessionPanel({
870
1006
  ? { from: catchUp.itemCount, since: catchUp.since }
871
1007
  : undefined
872
1008
  }
873
- reveal={reveal}
1009
+ /* On the way back, land on the Task you came from — see `returnReveal`.
1010
+ The host's own reveal still wins when it is the newer intent. */
1011
+ reveal={returnReveal ?? reveal}
1012
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
1013
+ onOpenSubagent={setSubagentId}
874
1014
  jumpToRecapRef={jumpToRecap}
875
1015
  repinRef={repinTranscript}
876
1016
  />
@@ -879,7 +1019,7 @@ export function SessionPanel({
879
1019
  itself opens pinned to the newest row. Held with the transcript
880
1020
  while the replay lands — its count is `state.items.length`, which
881
1021
  during the replay is a number visibly climbing toward its answer. */}
882
- {catchUp && newCount > 0 && !replaying ? (
1022
+ {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
883
1023
  <div className='px-3 pb-1'>
884
1024
  <div
885
1025
  data-slot='catch-up'
@@ -957,7 +1097,17 @@ export function SessionPanel({
957
1097
  </PromptSurface>
958
1098
  </div>
959
1099
  ) : null}
960
- {readOnly ? null : (
1100
+ {/* No composer while a sub-agent is framed: you cannot talk to one, and a
1101
+ live-looking input that silently addresses its *parent* is worse than
1102
+ no input at all. Its interrupt goes with it — Back is one press away,
1103
+ and a second interrupt control would be a second thing that stops
1104
+ something the reader is not looking at.
1105
+
1106
+ The approval prompts above deliberately do NOT go with it. A
1107
+ sub-agent's own tool calls raise session-level permission requests,
1108
+ so hiding them here would let the takeover deadlock the very agent it
1109
+ is showing until the reader happened to press Back. */}
1110
+ {readOnly || subagentId !== undefined ? null : (
961
1111
  <Composer
962
1112
  ref={composerRef}
963
1113
  onSend={handleSend}