@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
|
@@ -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'
|
|
@@ -63,6 +66,8 @@ import type { TerminalAffordances } from '../terminal/affordances.tsx'
|
|
|
63
66
|
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
64
67
|
import { StatusBar } from './StatusBar.tsx'
|
|
65
68
|
import { Transcript } from './Transcript.tsx'
|
|
69
|
+
import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
|
|
70
|
+
import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
|
|
66
71
|
import {
|
|
67
72
|
TranscriptDensityProvider,
|
|
68
73
|
TranscriptVariantProvider,
|
|
@@ -227,6 +232,33 @@ export interface SessionPanelProps {
|
|
|
227
232
|
* client's watermarks; a shared one is session metadata on the gateway).
|
|
228
233
|
*/
|
|
229
234
|
scrubberMarks?: readonly number[]
|
|
235
|
+
/**
|
|
236
|
+
* Scroll a tool call into view; bump `nonce` to ask again for the same one.
|
|
237
|
+
* See {@link TranscriptProps.reveal} — this is the panel's pass-through, and
|
|
238
|
+
* exists so a surface *outside* the panel (a sessions list showing a session's
|
|
239
|
+
* running sub-agents) can say "take me to that one" without opening a second
|
|
240
|
+
* attach to find out where it is.
|
|
241
|
+
*/
|
|
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 }
|
|
230
262
|
/**
|
|
231
263
|
* Terminal theme only: hold the prompt of the turn you are reading at the top
|
|
232
264
|
* of the transcript, as the Claude Code CLI does. The **real row** is pinned
|
|
@@ -441,6 +473,8 @@ export function SessionPanel({
|
|
|
441
473
|
terminalMetrics,
|
|
442
474
|
scrubber = false,
|
|
443
475
|
scrubberMarks,
|
|
476
|
+
reveal,
|
|
477
|
+
openSubagent,
|
|
444
478
|
stickyPrompt = false,
|
|
445
479
|
controlsSurface = 'internal',
|
|
446
480
|
onControls,
|
|
@@ -464,6 +498,29 @@ export function SessionPanel({
|
|
|
464
498
|
// is not an error channel.
|
|
465
499
|
const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
|
|
466
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)
|
|
467
524
|
const {
|
|
468
525
|
state,
|
|
469
526
|
connection,
|
|
@@ -479,10 +536,58 @@ export function SessionPanel({
|
|
|
479
536
|
setModel,
|
|
480
537
|
setPermissionMode,
|
|
481
538
|
reconnectNow,
|
|
539
|
+
loadFullResult,
|
|
482
540
|
} = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
|
|
483
541
|
// Callers are told to remount on a session switch, but a changed prop must not leave
|
|
484
542
|
// the previous session's failure on screen.
|
|
485
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])
|
|
486
591
|
|
|
487
592
|
// Catch-up is entered once, from the watermark the embedder handed over, and
|
|
488
593
|
// left when dismissed or when the user sends anything (they are plainly
|
|
@@ -523,6 +628,29 @@ export function SessionPanel({
|
|
|
523
628
|
// which for them never comes.
|
|
524
629
|
useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
|
|
525
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])
|
|
526
654
|
const capabilities = state.capabilities
|
|
527
655
|
|
|
528
656
|
// Plan usage as the *gateway* knows it, merged over this session's own
|
|
@@ -630,6 +758,10 @@ export function SessionPanel({
|
|
|
630
758
|
// path, never bytes). Stable and memoized per path: transcript rows re-render
|
|
631
759
|
// on every delta, and a fresh function would re-fetch each time.
|
|
632
760
|
const hostImage = useHostImage(client, sessionId, state.producedFiles)
|
|
761
|
+
// The other picture route, and the other store: an image *part* of a tool
|
|
762
|
+
// result, which an opted-in replay delivered as a reference rather than half a
|
|
763
|
+
// megabyte of base64. Bounded, unlike `useHostImage` — see the module.
|
|
764
|
+
const resultImages = useToolResultImages(client, sessionId)
|
|
633
765
|
const composerRef = useRef<ComposerHandle>(null)
|
|
634
766
|
// The catch-up strip's way of scrolling the (virtualized, usually unmounted)
|
|
635
767
|
// recap row into view — the transcript fills it in. See TranscriptProps.
|
|
@@ -807,6 +939,12 @@ export function SessionPanel({
|
|
|
807
939
|
// run, and they read it from this context rather than a prop chain.
|
|
808
940
|
<TranscriptVariantProvider value={transcriptVariant}>
|
|
809
941
|
<TranscriptDensityProvider value={transcriptDensity}>
|
|
942
|
+
{/* The panel owns the session's one attach, so it is the only thing that
|
|
943
|
+
can answer a row asking for the rest of a truncated tool result. Rows
|
|
944
|
+
rendered anywhere else fall back to the context's no-op, which is
|
|
945
|
+
correct for them: nothing truncates a replay they never asked for. */}
|
|
946
|
+
<ToolResultFetchProvider value={loadFullResult}>
|
|
947
|
+
<ToolResultImageProvider value={resultImages}>
|
|
810
948
|
<div
|
|
811
949
|
data-slot='session-panel'
|
|
812
950
|
// The typeface is a cascade fact, not a React one — one attribute here,
|
|
@@ -828,7 +966,27 @@ export function SessionPanel({
|
|
|
828
966
|
{protocolError}
|
|
829
967
|
</Notice>
|
|
830
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}
|
|
831
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'}
|
|
832
990
|
state={state}
|
|
833
991
|
fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
|
|
834
992
|
attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
|
|
@@ -848,6 +1006,11 @@ export function SessionPanel({
|
|
|
848
1006
|
? { from: catchUp.itemCount, since: catchUp.since }
|
|
849
1007
|
: undefined
|
|
850
1008
|
}
|
|
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}
|
|
851
1014
|
jumpToRecapRef={jumpToRecap}
|
|
852
1015
|
repinRef={repinTranscript}
|
|
853
1016
|
/>
|
|
@@ -856,7 +1019,7 @@ export function SessionPanel({
|
|
|
856
1019
|
itself opens pinned to the newest row. Held with the transcript
|
|
857
1020
|
while the replay lands — its count is `state.items.length`, which
|
|
858
1021
|
during the replay is a number visibly climbing toward its answer. */}
|
|
859
|
-
{catchUp && newCount > 0 && !replaying ? (
|
|
1022
|
+
{catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
|
|
860
1023
|
<div className='px-3 pb-1'>
|
|
861
1024
|
<div
|
|
862
1025
|
data-slot='catch-up'
|
|
@@ -934,7 +1097,17 @@ export function SessionPanel({
|
|
|
934
1097
|
</PromptSurface>
|
|
935
1098
|
</div>
|
|
936
1099
|
) : null}
|
|
937
|
-
{
|
|
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 : (
|
|
938
1111
|
<Composer
|
|
939
1112
|
ref={composerRef}
|
|
940
1113
|
onSend={handleSend}
|
|
@@ -1009,6 +1182,8 @@ export function SessionPanel({
|
|
|
1009
1182
|
</>
|
|
1010
1183
|
) : null}
|
|
1011
1184
|
</div>
|
|
1185
|
+
</ToolResultImageProvider>
|
|
1186
|
+
</ToolResultFetchProvider>
|
|
1012
1187
|
</TranscriptDensityProvider>
|
|
1013
1188
|
</TranscriptVariantProvider>
|
|
1014
1189
|
)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowRight,
|
|
3
|
+
Check,
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
CircleAlert,
|
|
7
|
+
Dot,
|
|
8
|
+
PauseCircle,
|
|
9
|
+
} from 'lucide-react'
|
|
10
|
+
import { isAgentRecord, subagentLabel } from '@workerdeck/protocol'
|
|
11
|
+
import type { SessionInfo, SubagentInfo } from '@workerdeck/protocol'
|
|
12
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
13
|
+
import { cn } from '../../lib/utils.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The work *under* a session row, and the one row shape every client renders it
|
|
17
|
+
* through.
|
|
18
|
+
*
|
|
19
|
+
* This lived inside the VS Code webview first, which is exactly why the
|
|
20
|
+
* dashboard had none of it. Nothing in here is extension-specific: a session's
|
|
21
|
+
* sub-agents are a protocol fact, and a disclosure over them is a list
|
|
22
|
+
* affordance. It sits in `packages/ui` so the sidebar, the dashboard and (in its
|
|
23
|
+
* own idiom) the phone are annotating rows the same way rather than three ways.
|
|
24
|
+
*
|
|
25
|
+
* Today the only source is `SessionInfo.subagents`. The other — the CLI's own
|
|
26
|
+
* **task checklist**, the to-do list it keeps for the current turn — is what the
|
|
27
|
+
* design was drawn from, and it is not built: nothing on the wire carries it yet
|
|
28
|
+
* (see `_docs/features/sub-agent-handling.md`, second thread, which opens with
|
|
29
|
+
* "check a capture" rather than "design a surface"). When it arrives it is a
|
|
30
|
+
* *source*, not a second row component: checklist when the session has one, its
|
|
31
|
+
* sub-agents otherwise.
|
|
32
|
+
*
|
|
33
|
+
* That is also why `state` has a `pending` arm no sub-agent can produce. A
|
|
34
|
+
* sub-agent record exists only once dispatched, so it is never queued; a to-do
|
|
35
|
+
* is queued for most of its life, and dropping the state would mean widening the
|
|
36
|
+
* union later — the shape is cheaper to state now than to retrofit.
|
|
37
|
+
*/
|
|
38
|
+
export type Step = {
|
|
39
|
+
key: string
|
|
40
|
+
label: string
|
|
41
|
+
/** What one of these is called, for the disclosure's count. */
|
|
42
|
+
noun: string
|
|
43
|
+
/**
|
|
44
|
+
* An **agent** has an identity and work of its own, so it is pressable and
|
|
45
|
+
* wears the sub-agent colour. A **task** is something the model described with
|
|
46
|
+
* no agent behind it (`isAgentRecord`), so it is inert: there is no frame to
|
|
47
|
+
* open, and a row that offered one would show an empty screen.
|
|
48
|
+
*
|
|
49
|
+
* Two values and not a boolean because the checklist source this shape was
|
|
50
|
+
* drawn for (see above) produces the second kind natively — a to-do is a task
|
|
51
|
+
* in exactly this sense, and it will slot in here rather than widening
|
|
52
|
+
* anything.
|
|
53
|
+
*/
|
|
54
|
+
kind: 'agent' | 'task'
|
|
55
|
+
state: 'done' | 'running' | 'pending' | 'failed'
|
|
56
|
+
/** A trailing reading — a sub-agent's tool count. Absent draws nothing. */
|
|
57
|
+
detail?: string
|
|
58
|
+
title: string
|
|
59
|
+
onSelect: () => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sessionSteps(
|
|
63
|
+
info: SessionInfo,
|
|
64
|
+
onSelectSubagent: (toolUseId: string) => void,
|
|
65
|
+
): Step[] {
|
|
66
|
+
// The label is protocol's `subagentLabel`, not a spelling of its own: the
|
|
67
|
+
// dashboard and the phone render the same rows from the same records, and two
|
|
68
|
+
// spellings would be two different answers to "which agent is this".
|
|
69
|
+
return (info.subagents ?? []).map((sub) => ({
|
|
70
|
+
key: sub.toolUseId,
|
|
71
|
+
label: subagentLabel(sub),
|
|
72
|
+
noun: 'agent',
|
|
73
|
+
kind: isAgentRecord(sub) ? ('agent' as const) : ('task' as const),
|
|
74
|
+
state: stepState(sub.status),
|
|
75
|
+
detail: sub.toolCount > 0 ? String(sub.toolCount) : undefined,
|
|
76
|
+
title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
|
|
77
|
+
onSelect: () => onSelectSubagent(sub.toolUseId),
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stepState(status: SubagentInfo['status']): Step['state'] {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case 'running':
|
|
84
|
+
return 'running'
|
|
85
|
+
case 'failed':
|
|
86
|
+
return 'failed'
|
|
87
|
+
default:
|
|
88
|
+
return 'done'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How many of these are still going — the live half of the disclosure's count. */
|
|
93
|
+
export function runningSteps(steps: readonly Step[]): number {
|
|
94
|
+
return steps.filter((s) => s.state === 'running').length
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The disclosure, which is also the reading: `3 agents` — or `2 of 3 agents`
|
|
99
|
+
* while some have settled, because "how many are still going" is the live
|
|
100
|
+
* question and a bare total answers it wrong the moment one finishes.
|
|
101
|
+
*
|
|
102
|
+
* Sub-agents are an annotation on a working row rather than a state of their own
|
|
103
|
+
* (see `runningSubagents` in protocol's `session-list.ts`), so this never
|
|
104
|
+
* competes with the row's status glyph: that still says what the *session* is
|
|
105
|
+
* doing.
|
|
106
|
+
*/
|
|
107
|
+
export function StepToggle({
|
|
108
|
+
expanded,
|
|
109
|
+
running,
|
|
110
|
+
total,
|
|
111
|
+
noun,
|
|
112
|
+
onToggle,
|
|
113
|
+
}: {
|
|
114
|
+
expanded: boolean
|
|
115
|
+
running: number
|
|
116
|
+
total: number
|
|
117
|
+
noun: string
|
|
118
|
+
onToggle: () => void
|
|
119
|
+
}) {
|
|
120
|
+
// Two spellings of one count. The **words** are the honest reading and go in
|
|
121
|
+
// the tooltip and to a screen reader; the line itself gets the digits, because
|
|
122
|
+
// this sits on the second line of a 280px row next to the folder and the age,
|
|
123
|
+
// and `1 of 6 agents` truncated the folder name away to say something the row
|
|
124
|
+
// could say in three characters.
|
|
125
|
+
const label = running > 0 && running < total ? `${running}/${total}` : String(total)
|
|
126
|
+
const words =
|
|
127
|
+
running > 0 && running < total
|
|
128
|
+
? `${running} of ${total} ${noun}s running`
|
|
129
|
+
: `${total} ${noun}${total === 1 ? '' : 's'}`
|
|
130
|
+
const Chevron = expanded ? ChevronDown : ChevronRight
|
|
131
|
+
return (
|
|
132
|
+
<button
|
|
133
|
+
type='button'
|
|
134
|
+
aria-expanded={expanded}
|
|
135
|
+
aria-label={`${expanded ? 'Hide' : 'Show'} ${words}`}
|
|
136
|
+
title={`${expanded ? 'Hide' : 'Show'} ${words}`}
|
|
137
|
+
onClick={(e) => {
|
|
138
|
+
// The whole row is a button and this one does not mean "select" — the
|
|
139
|
+
// same guard the row's overflow needs, and the reason a drag-select
|
|
140
|
+
// inside the row does not toggle it.
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
onToggle()
|
|
143
|
+
}}
|
|
144
|
+
className={cn(
|
|
145
|
+
'flex shrink-0 items-center gap-0.5 rounded px-0.5 outline-none',
|
|
146
|
+
'hover:bg-surface-hover hover:text-fg-2',
|
|
147
|
+
running > 0 ? 'text-info' : 'text-fg-4',
|
|
148
|
+
)}>
|
|
149
|
+
<Chevron className='size-3' />
|
|
150
|
+
<span className='tabular-nums'>{label}</span>
|
|
151
|
+
</button>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One step under its session. Pressing an **agent** hands the panel over to that
|
|
157
|
+
* agent's own work — it is not a session and never becomes one, but it does now
|
|
158
|
+
* have a surface (`SessionPanel.openSubagent`). A host that cannot frame one
|
|
159
|
+
* falls back to revealing its `Task` row, which was this row's only meaning
|
|
160
|
+
* before the takeover existed. A **task** is not pressable at all; see
|
|
161
|
+
* {@link Step.kind}.
|
|
162
|
+
*
|
|
163
|
+
* Divided from the row's header and from each other by a rule rather than by
|
|
164
|
+
* indentation: these are a list *inside* the row, and at 11px an indent is not
|
|
165
|
+
* enough to say so. The rule is black at 25% so it darkens whatever the row is
|
|
166
|
+
* filled with, selected or not, without needing a colour per state.
|
|
167
|
+
*/
|
|
168
|
+
export function StepRow({ step, onSelect }: { step: Step; onSelect: () => void }) {
|
|
169
|
+
const agent = step.kind === 'agent'
|
|
170
|
+
// Body colour by *kind*, state carried by the icon — the rule the transcript's
|
|
171
|
+
// own `TaskRow` already follows, where the body is green and the marker holds
|
|
172
|
+
// the beat. Green means sub-agent across this product (it is the one hue
|
|
173
|
+
// nothing else on a session surface claims), so a list that spent blue on
|
|
174
|
+
// "running" here would be saying something different from the transcript
|
|
175
|
+
// about the same agent. Failure still outranks it: an alarm is not a category.
|
|
176
|
+
const body = step.state === 'failed' ? 'text-danger' : agent ? 'text-success' : 'text-fg-4'
|
|
177
|
+
const content = (
|
|
178
|
+
<>
|
|
179
|
+
<StepIcon state={step.state} kind={step.kind} />
|
|
180
|
+
<span className='min-w-0 flex-1 truncate'>{step.label}</span>
|
|
181
|
+
{/* The progress reading while it works, and what it cost when it is done.
|
|
182
|
+
Zero draws nothing: `0 tools` beside a thinking agent reads as a stall,
|
|
183
|
+
which is the same call `taskSummary` makes one surface over. */}
|
|
184
|
+
{step.detail ? <span className='shrink-0 tabular-nums text-fg-4'>{step.detail}</span> : null}
|
|
185
|
+
{agent ? <ArrowRight className='size-3 shrink-0 opacity-60' /> : null}
|
|
186
|
+
</>
|
|
187
|
+
)
|
|
188
|
+
const shape =
|
|
189
|
+
'flex w-full items-center gap-2 border-t border-black/25 py-1 pl-3 pr-2 text-left text-label outline-none'
|
|
190
|
+
|
|
191
|
+
// A task is not a button, in the markup and not merely in the styling: there
|
|
192
|
+
// is nothing to press, and a disabled-looking button still announces itself as
|
|
193
|
+
// one. It stops the click all the same — the whole session row is pressable
|
|
194
|
+
// underneath, so falling through would open the session from a row that says
|
|
195
|
+
// it does nothing.
|
|
196
|
+
if (!agent) {
|
|
197
|
+
return (
|
|
198
|
+
<div title={step.title} onClick={(e) => e.stopPropagation()} className={cn(shape, body)}>
|
|
199
|
+
{content}
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return (
|
|
204
|
+
<button
|
|
205
|
+
type='button'
|
|
206
|
+
title={step.title}
|
|
207
|
+
onClick={(e) => {
|
|
208
|
+
e.stopPropagation()
|
|
209
|
+
onSelect()
|
|
210
|
+
}}
|
|
211
|
+
className={cn(shape, 'hover:bg-surface-hover', body)}>
|
|
212
|
+
{content}
|
|
213
|
+
</button>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function StepIcon({ state, kind }: { state: Step['state']; kind: Step['kind'] }) {
|
|
218
|
+
// A task that is neither running nor failed gets a neutral dot rather than a
|
|
219
|
+
// tick: `done` is a claim about work, and nothing here did any.
|
|
220
|
+
if (kind === 'task' && state !== 'running' && state !== 'failed') {
|
|
221
|
+
return <Dot className='size-3 shrink-0' />
|
|
222
|
+
}
|
|
223
|
+
switch (state) {
|
|
224
|
+
case 'running':
|
|
225
|
+
return <Spinner className='size-3 shrink-0' />
|
|
226
|
+
case 'failed':
|
|
227
|
+
return <CircleAlert className='size-3 shrink-0' />
|
|
228
|
+
case 'pending':
|
|
229
|
+
return <PauseCircle className='size-3 shrink-0' />
|
|
230
|
+
default:
|
|
231
|
+
return <Check className='size-3 shrink-0' />
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -41,6 +41,8 @@ export interface SessionWorkspaceProps {
|
|
|
41
41
|
scrubberMarks?: SessionPanelProps['scrubberMarks']
|
|
42
42
|
/** The last prompt pinned above the transcript — see `SessionPanel`. */
|
|
43
43
|
stickyPrompt?: SessionPanelProps['stickyPrompt']
|
|
44
|
+
/** Take the panel body over with one sub-agent's work — see `SessionPanel`. */
|
|
45
|
+
openSubagent?: SessionPanelProps['openSubagent']
|
|
44
46
|
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
45
47
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
46
48
|
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
@@ -103,6 +105,7 @@ export function SessionWorkspace({
|
|
|
103
105
|
scrubber,
|
|
104
106
|
scrubberMarks,
|
|
105
107
|
stickyPrompt,
|
|
108
|
+
openSubagent,
|
|
106
109
|
statusPlacement,
|
|
107
110
|
controlsSurface,
|
|
108
111
|
unseen,
|
|
@@ -307,6 +310,7 @@ export function SessionWorkspace({
|
|
|
307
310
|
scrubber={scrubber}
|
|
308
311
|
scrubberMarks={scrubberMarks}
|
|
309
312
|
stickyPrompt={stickyPrompt}
|
|
313
|
+
openSubagent={openSubagent}
|
|
310
314
|
controlsSurface={controlsSurface}
|
|
311
315
|
statusPlacement={statusPlacement}
|
|
312
316
|
unseen={unseen}
|
|
@@ -8,6 +8,7 @@ import { Spinner } from '../ui/Spinner.tsx'
|
|
|
8
8
|
import { Tip } from '../ui/Tooltip.tsx'
|
|
9
9
|
import { cn } from '../../lib/utils.ts'
|
|
10
10
|
import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
|
|
11
|
+
import { meterColorClass } from '../../lib/status.ts'
|
|
11
12
|
import { STATUS_META } from './status.ts'
|
|
12
13
|
import { useTranscriptVariant } from './transcript-variant.tsx'
|
|
13
14
|
|
|
@@ -63,8 +64,9 @@ function useNow(intervalMs = 30_000): number {
|
|
|
63
64
|
return now
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
/** The shared ramp, aliased for the two call sites below — the thresholds live
|
|
68
|
+
* in `lib/status.ts` beside the severity they mirror. */
|
|
69
|
+
const utilizationColor = (pct: number) => meterColorClass(pct)
|
|
68
70
|
|
|
69
71
|
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
70
72
|
* 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
|