@workerdeck/ui 0.17.0 → 0.19.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-DMPhsNlW.mjs → SessionPanel-CHktI2CF.mjs} +440 -75
- package/build/SessionPanel-CHktI2CF.mjs.map +1 -0
- package/build/{SessionPanel-CnNYEX80.d.mts → SessionPanel-CnU_IJ3-.d.mts} +24 -9
- 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 +237 -8
- package/build/index.mjs +399 -167
- 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 +4 -4
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/PermissionModeSelect.tsx +5 -1
- package/src/components/agent/SessionBrowser.tsx +115 -22
- package/src/components/agent/SessionPanel.tsx +153 -3
- 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/Transcript.tsx +138 -19
- package/src/components/agent/transcript-rows.ts +9 -1
- package/src/components/terminal/TerminalTranscript.tsx +77 -3
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +28 -0
- package/src/components/terminal/height.ts +24 -0
- package/src/components/terminal/items.tsx +1 -1
- package/src/components/terminal/scrubber.tsx +28 -3
- package/src/components/terminal/tool-run.ts +49 -5
- package/src/index.ts +19 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +10 -0
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DMPhsNlW.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
|
@@ -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
|
-
*
|
|
334
|
-
*
|
|
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.
|
|
361
|
-
//
|
|
362
|
-
|
|
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
|
|
394
|
-
|
|
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
|
-
<
|
|
473
|
+
<ContextRing usage={info.contextUsage} />
|
|
431
474
|
</div>
|
|
432
475
|
|
|
433
|
-
{/* Line two: what it is
|
|
434
|
-
|
|
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
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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 (
|
|
589
|
+
if (row.state === 'attention') {
|
|
497
590
|
return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
|
|
498
591
|
}
|
|
499
|
-
if (
|
|
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
|
-
|
|
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
|
-
{
|
|
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}
|