@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
|
@@ -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. */
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ArrowLeft } from 'lucide-react'
|
|
2
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { formatDuration } from '../../lib/format.ts'
|
|
5
|
+
import { Button } from '../ui/Button.tsx'
|
|
6
|
+
import { Ink, Row } from '../terminal/row.tsx'
|
|
7
|
+
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
8
|
+
import { taskBusy, taskFailed, taskIdentity } from '../terminal/tool-run.ts'
|
|
9
|
+
import type { ToolCallItem } from '../terminal/blocks.ts'
|
|
10
|
+
import { usePulse } from './pulse.tsx'
|
|
11
|
+
import { useTicker } from '../terminal/items.tsx'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one line above a sub-agent takeover: who this is, how it is doing, and the
|
|
15
|
+
* way back.
|
|
16
|
+
*
|
|
17
|
+
* **It claims exactly what the `TaskRow` it was opened from claims** — the same
|
|
18
|
+
* `taskBusy` / `taskFailed` / tool count over the same items — and deliberately
|
|
19
|
+
* *not* `SubagentInfo.status`. Protocol documents those two as divergent on
|
|
20
|
+
* purpose (`index.ts:1476-1487`), but that divergence is transcript-versus-*list*
|
|
21
|
+
* and this surface **is** the transcript. The disagreement that must not exist
|
|
22
|
+
* here is between a header and the rows directly beneath it.
|
|
23
|
+
*
|
|
24
|
+
* The rollup is still allowed one job — naming an agent whose `Task` call is not
|
|
25
|
+
* in the transcript — because a label is not content, and `SessionInfo.subagents`
|
|
26
|
+
* keeps only eight settled records (`SUBAGENT_HISTORY`), so it can never be the
|
|
27
|
+
* source of what a frame *shows*.
|
|
28
|
+
*
|
|
29
|
+
* Both variants, because the panel has two and a strip that only existed in one
|
|
30
|
+
* would make the takeover a terminal-theme feature rather than a session feature.
|
|
31
|
+
*/
|
|
32
|
+
export function SubagentStrip({
|
|
33
|
+
task,
|
|
34
|
+
items,
|
|
35
|
+
label,
|
|
36
|
+
onBack,
|
|
37
|
+
terminal,
|
|
38
|
+
fontSize,
|
|
39
|
+
lineHeight,
|
|
40
|
+
}: {
|
|
41
|
+
/** The spawning call. Absent when the transcript does not have it — the strip
|
|
42
|
+
* still draws, because the way back must exist even when the content cannot. */
|
|
43
|
+
task: ToolCallItem | undefined
|
|
44
|
+
/** The frame's items, for the busy reading and the tool count. */
|
|
45
|
+
items: readonly TranscriptItem[]
|
|
46
|
+
/** Fallback name when there is no task item. */
|
|
47
|
+
label: string
|
|
48
|
+
onBack: () => void
|
|
49
|
+
terminal: boolean
|
|
50
|
+
fontSize?: number
|
|
51
|
+
lineHeight?: number
|
|
52
|
+
}) {
|
|
53
|
+
const busy = task ? taskBusy(task, items) : false
|
|
54
|
+
const failed = task ? taskFailed(task) : false
|
|
55
|
+
const pulse = usePulse(busy)
|
|
56
|
+
const tools = items.reduce((n, item) => n + (item.kind === 'tool_call' ? 1 : 0), 0)
|
|
57
|
+
// Ticks only while it works, off the same once-a-second clock the working
|
|
58
|
+
// line uses. A settled agent has no end timestamp to measure against, and
|
|
59
|
+
// "N tools" is the settled reading anyway — a frozen clock would read as a
|
|
60
|
+
// stall. Absent `ts` (an item from before the reducer stamped it) simply
|
|
61
|
+
// draws no elapsed rather than counting from the epoch.
|
|
62
|
+
const startedAt = busy ? task?.ts : undefined
|
|
63
|
+
const now = useTicker(startedAt !== undefined)
|
|
64
|
+
const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
|
|
65
|
+
|
|
66
|
+
const name = task ? taskIdentity(task) : label
|
|
67
|
+
/**
|
|
68
|
+
* Running or finished, in the theme's own words rather than new ones:
|
|
69
|
+
* `taskSummary` already says `working…` and `done` for exactly this state one
|
|
70
|
+
* row over, and a header that said "running"/"completed" about the same agent
|
|
71
|
+
* would be a second vocabulary for one fact. The trailing ellipsis is the
|
|
72
|
+
* theme's in-flight signal; the pulse beside it is the beat.
|
|
73
|
+
*
|
|
74
|
+
* Unknown when the transcript has no `Task` call to read — better silent than
|
|
75
|
+
* confidently wrong about an agent we cannot see.
|
|
76
|
+
*/
|
|
77
|
+
const status = !task ? undefined : failed ? 'failed' : busy ? `${pulse} working…` : 'done'
|
|
78
|
+
const detail = [tools > 0 ? `${tools} tool${tools === 1 ? '' : 's'}` : undefined, elapsed]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(' · ')
|
|
81
|
+
|
|
82
|
+
if (terminal) {
|
|
83
|
+
return (
|
|
84
|
+
<TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className='shrink-0'>
|
|
85
|
+
{/* A row on the grid, not a chrome bar: the takeover is a mode of the
|
|
86
|
+
transcript, and a toolbar in some other metric above it would read as
|
|
87
|
+
a different application's. The whole line is the target — there is
|
|
88
|
+
exactly one thing to do here. */}
|
|
89
|
+
<button
|
|
90
|
+
type='button'
|
|
91
|
+
onClick={onBack}
|
|
92
|
+
aria-label='Back to the session'
|
|
93
|
+
className='block w-full cursor-pointer text-left'>
|
|
94
|
+
{/* The arrow is in the gutter unconditionally. It used to give way to
|
|
95
|
+
the pulse while the agent worked, which put the way *out* of the
|
|
96
|
+
frame on a timer — the one control here should not come and go.
|
|
97
|
+
The beat moved into the status instead. `indent` because this is a
|
|
98
|
+
frame around the rows rather than one of them, and a marker flush
|
|
99
|
+
against the panel edge reads as a clipped row. */}
|
|
100
|
+
<Row glyph='←' glyphTone='dim' indent={1} tone={failed ? 'red' : 'green'}>
|
|
101
|
+
{name}
|
|
102
|
+
{status ? (
|
|
103
|
+
<Ink tone={failed ? 'red' : busy ? 'mark' : 'dim'}> · {status}</Ink>
|
|
104
|
+
) : null}
|
|
105
|
+
{detail ? <Ink tone='faint'> · {detail}</Ink> : null}
|
|
106
|
+
</Row>
|
|
107
|
+
</button>
|
|
108
|
+
</TerminalSurface>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className='flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5'>
|
|
114
|
+
<Button variant='ghost' size='sm' onClick={onBack} className='h-6 gap-1 px-1.5'>
|
|
115
|
+
<ArrowLeft className='size-3.5' />
|
|
116
|
+
Back
|
|
117
|
+
</Button>
|
|
118
|
+
<span
|
|
119
|
+
className={cn('min-w-0 flex-1 truncate text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>
|
|
120
|
+
{name}
|
|
121
|
+
</span>
|
|
122
|
+
{status ? (
|
|
123
|
+
<span
|
|
124
|
+
className={cn(
|
|
125
|
+
'shrink-0 text-label',
|
|
126
|
+
failed ? 'text-danger' : busy ? 'text-accent' : 'text-fg-3',
|
|
127
|
+
)}>
|
|
128
|
+
{status}
|
|
129
|
+
</span>
|
|
130
|
+
) : null}
|
|
131
|
+
{detail ? <span className='shrink-0 text-label text-fg-4'>{detail}</span> : null}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|