@workerdeck/ui 0.20.0 → 0.22.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 +3 -2
- package/build/{SessionPanel-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
- package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
- package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
- package/build/index.d.mts +309 -53
- package/build/index.mjs +534 -397
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +3547 -0
- package/build/workspace.d.mts +10 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +15 -7
- package/src/components/agent/Composer.tsx +10 -10
- package/src/components/agent/ContextDialog.tsx +3 -2
- package/src/components/agent/Conversation.tsx +1 -1
- package/src/components/agent/McpDialog.tsx +3 -1
- package/src/components/agent/Scrubber.tsx +248 -0
- package/src/components/agent/SessionBrowser.tsx +113 -293
- package/src/components/agent/SessionItem.tsx +538 -0
- package/src/components/agent/SessionList.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +112 -21
- package/src/components/agent/SessionStatusIcon.tsx +43 -0
- package/src/components/agent/SessionSteps.tsx +118 -61
- package/src/components/agent/SessionWorkspace.tsx +11 -0
- package/src/components/agent/SkillsDialog.tsx +3 -2
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/Transcript.tsx +147 -73
- package/src/components/agent/UsageDialog.tsx +3 -1
- package/src/components/agent/scrubber-marks.ts +277 -0
- package/src/components/terminal/PermissionPrompt.tsx +3 -0
- package/src/components/terminal/QuestionPrompt.tsx +3 -0
- package/src/components/terminal/StatusLine.tsx +3 -1
- package/src/components/ui/AlertDialog.tsx +23 -20
- package/src/components/ui/Dialog.tsx +26 -23
- package/src/components/ui/Menu.tsx +20 -17
- package/src/components/ui/PortalScope.tsx +27 -0
- package/src/components/ui/Select.tsx +19 -16
- package/src/components/ui/Tooltip.tsx +13 -10
- package/src/index.ts +5 -1
- package/src/styles/scoped.entry.css +16 -0
- package/src/styles/terminal.css +1 -1
- package/src/styles/theme.css +223 -8
- package/build/SessionPanel-CHktI2CF.mjs.map +0 -1
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { projectLabel, projectSubpath, sessionLabel } from '@workerdeck/protocol'
|
|
4
|
+
import type { SessionRow } from '@workerdeck/protocol'
|
|
5
|
+
import { ContextRing } from './ContextRing.tsx'
|
|
6
|
+
import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
|
|
7
|
+
import { ProjectIcon } from './ProjectIcon.tsx'
|
|
8
|
+
import { SessionStatusIcon } from './SessionStatusIcon.tsx'
|
|
9
|
+
import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
|
|
10
|
+
import type { Step } from './SessionSteps.tsx'
|
|
11
|
+
import { cn } from '../../lib/utils.ts'
|
|
12
|
+
import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One session in a list — the **card**, and the only drawing of it in the
|
|
16
|
+
* product.
|
|
17
|
+
*
|
|
18
|
+
* The dashboard's `SessionBrowser` and the VS Code sidebar both render this;
|
|
19
|
+
* before it existed they rendered two hand-kept copies that agreed on the model
|
|
20
|
+
* and disagreed on every measurement, and the two lists read as two products.
|
|
21
|
+
* iOS mirrors this file's geometry rather than either copy.
|
|
22
|
+
*
|
|
23
|
+
* ## The shape
|
|
24
|
+
*
|
|
25
|
+
* A 4px-padded, 4px-rounded card whose whole surface is the hit target, holding
|
|
26
|
+
* two 20px lines and, when it is open, the work under them.
|
|
27
|
+
*
|
|
28
|
+
* **Line one is state and identity**: the status glyph leads, the title takes
|
|
29
|
+
* the rest, and the line closes with the unread badge and the context ring. The
|
|
30
|
+
* glyph leads because a sessions list is scanned for *state* first — which of
|
|
31
|
+
* these is working, which is waiting on me — and the title is what you read once
|
|
32
|
+
* the glyph has told you which row to read. The two readings that close the line
|
|
33
|
+
* are the two that change while you are looking at them.
|
|
34
|
+
*
|
|
35
|
+
* **Line two is identity and cost**: the engine's mark and its model in the
|
|
36
|
+
* vendor's own colour, then the project, then everything else the host wants
|
|
37
|
+
* said, then the age — a `·`-joined run that ends in the one part of it that
|
|
38
|
+
* keeps moving. The line's right edge belongs to the disclosure (which doubles
|
|
39
|
+
* as the count of the work below, so the card says how much there is without
|
|
40
|
+
* being opened) and to the host's own actions.
|
|
41
|
+
*
|
|
42
|
+
* **The gutter is one 16px cell, shared by both lines.** The two glyphs are
|
|
43
|
+
* different sizes and laying them out as plain flex children starts the two
|
|
44
|
+
* text columns at different x — two pixels, invisible as a measurement and
|
|
45
|
+
* obvious as a misalignment. Centring inside a fixed cell fixes the text edges
|
|
46
|
+
* and the ink centres at once. It is the terminal gutter's argument at card
|
|
47
|
+
* scale.
|
|
48
|
+
*
|
|
49
|
+
* ## Selection
|
|
50
|
+
*
|
|
51
|
+
* **Selection is the card's own fill**, not a gutter bar: the card is an inset
|
|
52
|
+
* shape with air around it, so filling it is unambiguous in a way a fill on a
|
|
53
|
+
* full-bleed row is not, and it leaves the left edge to the state glyph.
|
|
54
|
+
*
|
|
55
|
+
* There are **two selections at two grains**, and the card and its steps split
|
|
56
|
+
* them:
|
|
57
|
+
*
|
|
58
|
+
* | what is on screen | card | the step |
|
|
59
|
+
* | --- | --- | --- |
|
|
60
|
+
* | nothing | transparent, hovers | transparent, hovers |
|
|
61
|
+
* | this session | `--row-selected` (blue) | transparent, hovers |
|
|
62
|
+
* | one of its sub-agents | `--row-selected-weak` (grey) | `--row-selected` (blue) |
|
|
63
|
+
*
|
|
64
|
+
* **The blue always marks the finest thing selected.** Opening a sub-agent
|
|
65
|
+
* selects its session too, so both claims are true at once and the blue can only
|
|
66
|
+
* carry one; it goes to the agent, and the card steps back to grey — present,
|
|
67
|
+
* holding what you are looking at, not itself the thing you are looking at.
|
|
68
|
+
* Reversing that (blue card, blue row inside it) says nothing, which is what a
|
|
69
|
+
* blue-on-blue card looked like.
|
|
70
|
+
*
|
|
71
|
+
* A filled card does **not** also answer hover: the fill is already spent, and a
|
|
72
|
+
* selected row that lightened under the pointer read as a third state nobody
|
|
73
|
+
* could name. Its steps still do, on `--row-active`, which is a tint and so
|
|
74
|
+
* works on all three grounds.
|
|
75
|
+
*
|
|
76
|
+
* A **task** never takes the blue. It is a reference to a place inside a
|
|
77
|
+
* session, so pressing one selects the session and travels to its marker — see
|
|
78
|
+
* `StepRow`.
|
|
79
|
+
*/
|
|
80
|
+
export interface SessionItemProps {
|
|
81
|
+
row: SessionRow
|
|
82
|
+
/** This session is the one the host is showing. */
|
|
83
|
+
active?: boolean
|
|
84
|
+
/**
|
|
85
|
+
* Which sub-agent is open, by `Step.key` — the *finer* of the two selections.
|
|
86
|
+
* When it names one of this card's agents, that step takes the blue and the
|
|
87
|
+
* card drops to the secondary grey. A key belonging to some other card's
|
|
88
|
+
* agent, or to a task, changes nothing here.
|
|
89
|
+
*/
|
|
90
|
+
activeStepKey?: string
|
|
91
|
+
/** Shown when the list is not already grouped by gateway. */
|
|
92
|
+
showGateway?: boolean
|
|
93
|
+
/**
|
|
94
|
+
* False when the list is already grouped by project — the header above has
|
|
95
|
+
* said the name, so the card must not spend its metadata line repeating it.
|
|
96
|
+
* What takes the slot is the *sub-path inside the project*
|
|
97
|
+
* (`projectSubpath`): `packages/ui`, `packages/server` — the one thing the
|
|
98
|
+
* header cannot say and the only thing that tells two sessions in one repo
|
|
99
|
+
* apart. A session at the project root has nothing to add and the slot
|
|
100
|
+
* disappears entirely. Exactly the rule `showGateway` follows one facet over.
|
|
101
|
+
*/
|
|
102
|
+
showProject?: boolean
|
|
103
|
+
/** Resolved project-icon bytes by content hash. A hash this map has not got
|
|
104
|
+
* yet simply draws no icon. */
|
|
105
|
+
projectIcons?: Record<string, string>
|
|
106
|
+
/** Uncontrolled by default — pass both to drive expansion from the host. */
|
|
107
|
+
expanded?: boolean
|
|
108
|
+
onExpandedChange?: (expanded: boolean) => void
|
|
109
|
+
onSelect?: () => void
|
|
110
|
+
/** Open the session *at* one of its sub-agents — hand the panel body over to
|
|
111
|
+
* that agent's own work. Only ever called for an **agent** step. Absent is not
|
|
112
|
+
* a missing feature: without it a step just opens the session, which is all a
|
|
113
|
+
* host with no sub-agent surface can offer. */
|
|
114
|
+
onSelectSubagent?: (toolUseId: string) => void
|
|
115
|
+
/**
|
|
116
|
+
* Open the session and travel to a **task**'s row in the transcript — where
|
|
117
|
+
* the work was started, and where it finished.
|
|
118
|
+
*
|
|
119
|
+
* A separate seam from `onSelectSubagent` because the destinations are
|
|
120
|
+
* different, and conflating them is not a styling detail: a task has no agent
|
|
121
|
+
* behind it, so framing its tool-use id selects **no items at all** and the
|
|
122
|
+
* panel draws an empty agent view. That was the bug. A task is a reference,
|
|
123
|
+
* and the only honest thing to do with a reference is follow it.
|
|
124
|
+
*/
|
|
125
|
+
onRevealStep?: (toolUseId: string) => void
|
|
126
|
+
/** Enables in-place rename. The trigger is the host's — see `renameOn`. */
|
|
127
|
+
onRename?: (title: string) => void
|
|
128
|
+
/**
|
|
129
|
+
* How a rename starts. `doubleClick` is the editor feel (a rename is a thing
|
|
130
|
+
* you do to the word you are looking at); `external` means the host opens it
|
|
131
|
+
* from its own affordance — a menu entry, a pencil in `actions` — and drives
|
|
132
|
+
* it through `editing`/`onEditingChange`.
|
|
133
|
+
*/
|
|
134
|
+
renameOn?: 'doubleClick' | 'external'
|
|
135
|
+
editing?: boolean
|
|
136
|
+
onEditingChange?: (editing: boolean) => void
|
|
137
|
+
/** The card's trailing controls, at the end of line two. A single overflow
|
|
138
|
+
* glyph is what the design draws; a host with three always-on actions can put
|
|
139
|
+
* them here instead. */
|
|
140
|
+
actions?: ReactNode
|
|
141
|
+
className?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function SessionItem({
|
|
145
|
+
row,
|
|
146
|
+
active = false,
|
|
147
|
+
activeStepKey,
|
|
148
|
+
showGateway,
|
|
149
|
+
showProject = true,
|
|
150
|
+
projectIcons,
|
|
151
|
+
expanded,
|
|
152
|
+
onExpandedChange,
|
|
153
|
+
onSelect,
|
|
154
|
+
onSelectSubagent,
|
|
155
|
+
onRevealStep,
|
|
156
|
+
onRename,
|
|
157
|
+
renameOn = 'doubleClick',
|
|
158
|
+
editing,
|
|
159
|
+
onEditingChange,
|
|
160
|
+
actions,
|
|
161
|
+
className,
|
|
162
|
+
}: SessionItemProps) {
|
|
163
|
+
const { info } = row
|
|
164
|
+
// Expansion is the card's own state unless the host takes it, and is
|
|
165
|
+
// deliberately not persisted: a list that reopened yesterday's work on every
|
|
166
|
+
// reload would be showing a settled tail nobody asked for.
|
|
167
|
+
const [ownExpanded, setOwnExpanded] = useState(false)
|
|
168
|
+
const open = expanded ?? ownExpanded
|
|
169
|
+
const setOpen = (next: boolean) => {
|
|
170
|
+
setOwnExpanded(next)
|
|
171
|
+
onExpandedChange?.(next)
|
|
172
|
+
}
|
|
173
|
+
const [ownEditing, setOwnEditing] = useState(false)
|
|
174
|
+
const isEditing = editing ?? ownEditing
|
|
175
|
+
const setEditing = (next: boolean) => {
|
|
176
|
+
setOwnEditing(next)
|
|
177
|
+
onEditingChange?.(next)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const engine = info.engine ?? 'claude'
|
|
181
|
+
// protocol's own spelling, so this card, the group header above it and the
|
|
182
|
+
// project facet cannot disagree about what a project is called.
|
|
183
|
+
const project = showProject ? projectLabel(row) : projectSubpath(row)
|
|
184
|
+
const projectIcon = showProject ? info.project?.icon : undefined
|
|
185
|
+
const iconSrc = projectIcon?.type === 'image' ? projectIcons?.[projectIcon.hash] : undefined
|
|
186
|
+
// Everything between the project and the age. Plain strings, because none of
|
|
187
|
+
// them wears a colour or an icon — the two that do are drawn as their own
|
|
188
|
+
// cells either side of this run.
|
|
189
|
+
//
|
|
190
|
+
// A session that has spent nothing contributes **nothing**, rather than
|
|
191
|
+
// `formatCost`'s em-dash. A dash is the right answer in a details panel, where
|
|
192
|
+
// the row exists to be read and an empty cell would look broken; here it is a
|
|
193
|
+
// segment of a `·`-joined run competing for the width the project name needs,
|
|
194
|
+
// and `· — ·` is punctuation pretending to be a reading.
|
|
195
|
+
const cost = formatCost(info.totalCostUsd)
|
|
196
|
+
const extras = [
|
|
197
|
+
showGateway ? row.hostName : undefined,
|
|
198
|
+
info.profile ? `@${info.profile}` : undefined,
|
|
199
|
+
cost === '—' ? undefined : cost,
|
|
200
|
+
].filter((part): part is string => Boolean(part))
|
|
201
|
+
|
|
202
|
+
// The metadata run, assembled as a **list of present parts** rather than as a
|
|
203
|
+
// template with conditionals in it. Every one of these is genuinely optional —
|
|
204
|
+
// a session reports no model until its first turn, a session outside any
|
|
205
|
+
// declared project has no name to show, a session at its project's root has no
|
|
206
|
+
// sub-path — and a template makes each absence a separate place to remember
|
|
207
|
+
// the separator. Built as a list, the separator is drawn *between* parts and
|
|
208
|
+
// there is exactly one rule to get right.
|
|
209
|
+
//
|
|
210
|
+
// This is not hypothetical tidiness: `friendlyModel(undefined)` is `undefined`,
|
|
211
|
+
// so a session with no model yet drew an empty span followed by the project's
|
|
212
|
+
// leading `· `, and the line opened with a separator attached to nothing.
|
|
213
|
+
const model = friendlyModel(info.model)
|
|
214
|
+
const parts: ReactNode[] = []
|
|
215
|
+
if (model) {
|
|
216
|
+
parts.push(
|
|
217
|
+
// The model id as a person says it — `claude-opus-5[1m]` is a wire value,
|
|
218
|
+
// and a card line has no room for a context-window suffix.
|
|
219
|
+
<span key='model' className={vendorTextClass(engine, info.model)}>
|
|
220
|
+
{model}
|
|
221
|
+
</span>,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
if (project !== undefined) {
|
|
225
|
+
parts.push(
|
|
226
|
+
<span key='project'>
|
|
227
|
+
<ProjectIcon
|
|
228
|
+
icon={projectIcon}
|
|
229
|
+
src={iconSrc}
|
|
230
|
+
name={project}
|
|
231
|
+
/* 16px, the same box as the engine mark in the gutter, and nudged onto
|
|
232
|
+
the text baseline: the box is taller than the line, so it sits a
|
|
233
|
+
hair proud without this. */
|
|
234
|
+
className='mr-1.5 size-4 align-[-0.3em]'
|
|
235
|
+
/>
|
|
236
|
+
{project}
|
|
237
|
+
</span>,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
for (const extra of extras) parts.push(<span key={extra}>{extra}</span>)
|
|
241
|
+
// Where a step press goes, by kind. An **agent** has work of its own and gets
|
|
242
|
+
// the frame; a **task** is a marker and gets travelled to. Both fall back to
|
|
243
|
+
// plainly opening the session, which is the whole of what a host that can do
|
|
244
|
+
// neither has to offer — and is still better than a destination that renders
|
|
245
|
+
// empty.
|
|
246
|
+
const steps = sessionSteps(info, (toolUseId, kind) => {
|
|
247
|
+
if (kind === 'agent') return onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.()
|
|
248
|
+
return onRevealStep ? onRevealStep(toolUseId) : onSelect?.()
|
|
249
|
+
})
|
|
250
|
+
// Whether the thing on screen is one of THIS card's sub-agents. Matched
|
|
251
|
+
// against the card's own steps rather than taken on trust from `activeStepKey`
|
|
252
|
+
// alone: every card in the list is handed the same key, and a bare truthiness
|
|
253
|
+
// check would turn all of them grey the moment any one agent opened. Tasks are
|
|
254
|
+
// excluded because a task is a place to go, not a thing to hold — see
|
|
255
|
+
// `StepRow`.
|
|
256
|
+
const holdsOpenAgent = steps.some((s) => s.kind === 'agent' && s.key === activeStepKey)
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
data-slot='session-item'
|
|
261
|
+
data-active={active || undefined}
|
|
262
|
+
role='button'
|
|
263
|
+
tabIndex={0}
|
|
264
|
+
/* Only the FIRST click of a click-streak selects. Selecting reveals the
|
|
265
|
+
session and focuses its composer, so the second click of a double-click
|
|
266
|
+
would steal focus a beat after the rename editor mounted — the editor
|
|
267
|
+
appearing and vanishing in the same gesture. `detail` counts the
|
|
268
|
+
streak; anything past the first is the double-click the title is
|
|
269
|
+
listening for. */
|
|
270
|
+
onClick={(e) => {
|
|
271
|
+
if (e.detail > 1 || isEditing) return
|
|
272
|
+
onSelect?.()
|
|
273
|
+
}}
|
|
274
|
+
onKeyDown={(e) => {
|
|
275
|
+
// Only when the card ITSELF has focus. A press on something inside it —
|
|
276
|
+
// the disclosure, a step, the overflow — already fires that control's
|
|
277
|
+
// own click, and `stopPropagation` there cannot help: the keydown is a
|
|
278
|
+
// separate event travelling the same path, so an unguarded handler ran
|
|
279
|
+
// the control's action AND selected the session.
|
|
280
|
+
if (e.target !== e.currentTarget) return
|
|
281
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
282
|
+
e.preventDefault()
|
|
283
|
+
onSelect?.()
|
|
284
|
+
}
|
|
285
|
+
}}
|
|
286
|
+
className={cn(
|
|
287
|
+
'group flex w-full cursor-pointer flex-col p-1 text-left outline-none',
|
|
288
|
+
'rounded-[4px] transition-colors focus-visible:ring-2 focus-visible:ring-ring',
|
|
289
|
+
// Three states, one place, in priority order. A sub-agent being open
|
|
290
|
+
// OUTRANKS the session being selected, because both are true at once —
|
|
291
|
+
// opening an agent selects its session too — and the blue can only mean
|
|
292
|
+
// one of them. It goes to the finer claim; the card keeps the coarser
|
|
293
|
+
// one in grey. A filled card does not also answer hover: the fill is
|
|
294
|
+
// already spent, and a selected row that lightened under the pointer
|
|
295
|
+
// read as a third state nobody could name.
|
|
296
|
+
holdsOpenAgent
|
|
297
|
+
? 'bg-row-selected-weak'
|
|
298
|
+
: active
|
|
299
|
+
? 'bg-row-selected'
|
|
300
|
+
: 'hover:bg-row-hover',
|
|
301
|
+
className,
|
|
302
|
+
)}>
|
|
303
|
+
<div className='flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5'>
|
|
304
|
+
{/* Line one — state, name, and the two readings that keep moving.
|
|
305
|
+
|
|
306
|
+
**6px between cells, where the column between the lines is 4px.**
|
|
307
|
+
Not a rounding wobble: a horizontal gap separates a glyph from the
|
|
308
|
+
words it labels, and 4px put the two close enough to read as one
|
|
309
|
+
smudge at 13px; a vertical gap separates two lines that are already
|
|
310
|
+
separated by their own leading, so it needs less. The design was
|
|
311
|
+
redrawn to this once the vendor marks lost the transparent padding
|
|
312
|
+
that had been standing in for the missing space. */}
|
|
313
|
+
<div className='flex h-5 items-center gap-1.5 overflow-hidden'>
|
|
314
|
+
<Gutter>
|
|
315
|
+
<SessionStatusIcon row={row} />
|
|
316
|
+
</Gutter>
|
|
317
|
+
{isEditing && onRename ? (
|
|
318
|
+
/* The editor replaces the title rather than sitting inside it — an
|
|
319
|
+
input nested in a button is invalid markup, and disabling the
|
|
320
|
+
button to protect the edit disables the field with it. */
|
|
321
|
+
<NameEditor
|
|
322
|
+
initial={info.title ?? ''}
|
|
323
|
+
onCommit={(title) => {
|
|
324
|
+
setEditing(false)
|
|
325
|
+
if (title !== (info.title ?? '')) onRename(title)
|
|
326
|
+
}}
|
|
327
|
+
onCancel={() => setEditing(false)}
|
|
328
|
+
/>
|
|
329
|
+
) : (
|
|
330
|
+
<span
|
|
331
|
+
onDoubleClick={
|
|
332
|
+
onRename && renameOn === 'doubleClick'
|
|
333
|
+
? (e) => {
|
|
334
|
+
e.stopPropagation()
|
|
335
|
+
setEditing(true)
|
|
336
|
+
}
|
|
337
|
+
: undefined
|
|
338
|
+
}
|
|
339
|
+
className='min-w-0 flex-1 truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1'>
|
|
340
|
+
{sessionLabel(info)}
|
|
341
|
+
</span>
|
|
342
|
+
)}
|
|
343
|
+
{row.unseen > 0 ? (
|
|
344
|
+
/* Transcript rows since this session was last on screen — the same
|
|
345
|
+
unit the VS Code activity-bar badge counts, because turns
|
|
346
|
+
undercount badly.
|
|
347
|
+
|
|
348
|
+
**The colour is the state's, not the count's.** On a live session
|
|
349
|
+
unread is a call to look, and it wears the accent. On a settled
|
|
350
|
+
one the same number is a *record* — the turn is over, nothing is
|
|
351
|
+
going to arrive, and there is nothing to answer — so it drops to
|
|
352
|
+
the neutral badge. A list where every finished session still
|
|
353
|
+
shouted in blue is a list where the blue stopped meaning
|
|
354
|
+
anything, which is the whole reason the design draws two. */
|
|
355
|
+
<span
|
|
356
|
+
title={`${row.unseen} new`}
|
|
357
|
+
className={cn(
|
|
358
|
+
'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
|
|
359
|
+
'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
|
|
360
|
+
row.state === 'working' || row.state === 'attention'
|
|
361
|
+
? 'bg-accent text-accent-fg'
|
|
362
|
+
: 'bg-badge text-badge-fg',
|
|
363
|
+
)}>
|
|
364
|
+
{row.unseen}
|
|
365
|
+
</span>
|
|
366
|
+
) : null}
|
|
367
|
+
{/* How full the window is, at a glance and across the whole list — the
|
|
368
|
+
question you cannot ask from inside one session. Absent draws
|
|
369
|
+
nothing, and absent is not zero. */}
|
|
370
|
+
<ContextRing usage={info.contextUsage} size={16} className='p-0.5' />
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Line two — what it is, where it runs, what it cost, how old it is. */}
|
|
374
|
+
<div className='flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]'>
|
|
375
|
+
<Gutter>
|
|
376
|
+
{/* The colour goes ON the icon, not on a parent: the svg ships its
|
|
377
|
+
own `text-fg-3` and only a class on the element itself merges
|
|
378
|
+
over it. `cn` is tailwind-merge, so passing one replaces the
|
|
379
|
+
default rather than losing to it. */}
|
|
380
|
+
<EngineIcon
|
|
381
|
+
engine={engine}
|
|
382
|
+
model={info.model}
|
|
383
|
+
className={cn('size-4', vendorMarkClass(engine, info.model))}
|
|
384
|
+
/>
|
|
385
|
+
</Gutter>
|
|
386
|
+
{/* **One truncating run and one atom, not five cells and not one
|
|
387
|
+
span**, and both halves of that are load-bearing.
|
|
388
|
+
|
|
389
|
+
The run is one span because its parts have a priority order —
|
|
390
|
+
model, then project, then where it ran and what it cost — and a
|
|
391
|
+
single ellipsis honours it for free. As five flex children they
|
|
392
|
+
cannot: `shrink-0` everywhere clips mid-glyph (`$12.4` cut in
|
|
393
|
+
half, a project name gone entirely behind its own icon), and
|
|
394
|
+
letting them shrink costs each of them a little and leaves four
|
|
395
|
+
half-words with no ellipsis anywhere. The project glyph rides
|
|
396
|
+
inside as an inline element for the same reason — it clips with
|
|
397
|
+
the text it labels instead of holding a slot that text has already
|
|
398
|
+
lost.
|
|
399
|
+
|
|
400
|
+
**The age is not in the run.** It sat at the end of it and was
|
|
401
|
+
therefore the first thing an ellipsis ate, which is precisely
|
|
402
|
+
backwards: `4m ago` is three characters that answer "is this
|
|
403
|
+
still moving", and a truncated `4m …` answers nothing while a
|
|
404
|
+
truncated project name still says which repo. So it is its own
|
|
405
|
+
shrink-0 cell and the run yields to it. That is one ellipsis in
|
|
406
|
+
one place, which is the whole reason not to use five cells. */}
|
|
407
|
+
<span className='min-w-0 truncate text-fg-4'>
|
|
408
|
+
{/* Separators live BETWEEN parts and are never attached to one.
|
|
409
|
+
Attached, a part that turned out to be absent left its own
|
|
410
|
+
separator behind — which is what a session with no model
|
|
411
|
+
recorded yet did: `friendlyModel(undefined)` is `undefined`, the
|
|
412
|
+
model drew nothing, and the line opened with a `· ` in front of
|
|
413
|
+
the project, hanging off nothing. */}
|
|
414
|
+
{parts.map((part, i) => (
|
|
415
|
+
<Fragment key={i}>
|
|
416
|
+
{i > 0 ? ' · ' : ''}
|
|
417
|
+
{part}
|
|
418
|
+
</Fragment>
|
|
419
|
+
))}
|
|
420
|
+
</span>
|
|
421
|
+
<span className='shrink-0 text-fg-4'>
|
|
422
|
+
{parts.length > 0 ? '· ' : ''}
|
|
423
|
+
{formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
|
|
424
|
+
</span>
|
|
425
|
+
{/* The slack goes HERE, after the age — not into the identity run.
|
|
426
|
+
Giving the run `flex-1` made it absorb the surplus, which pushed
|
|
427
|
+
the age to the far right on a card with no disclosure and left a
|
|
428
|
+
hole between a project name and its own timestamp. The design
|
|
429
|
+
draws the same spacer in the same place for the same reason: the
|
|
430
|
+
run and the age are one reading, and what floats is the gap before
|
|
431
|
+
the controls. Basis 0, so it only ever takes *surplus* — under a
|
|
432
|
+
deficit it is worth nothing and the run truncates instead. */}
|
|
433
|
+
<span className='min-w-0 flex-1' />
|
|
434
|
+
{steps.length > 0 ? (
|
|
435
|
+
<StepToggle
|
|
436
|
+
expanded={open}
|
|
437
|
+
running={runningSteps(steps)}
|
|
438
|
+
total={steps.length}
|
|
439
|
+
noun={steps[0]!.noun}
|
|
440
|
+
onToggle={() => setOpen(!open)}
|
|
441
|
+
/>
|
|
442
|
+
) : null}
|
|
443
|
+
{actions}
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
{open && steps.length > 0 ? (
|
|
448
|
+
<div className='flex flex-col'>
|
|
449
|
+
{steps.map((step: Step) => (
|
|
450
|
+
<StepRow
|
|
451
|
+
key={step.key}
|
|
452
|
+
step={step}
|
|
453
|
+
active={step.key === activeStepKey}
|
|
454
|
+
onSelect={step.onSelect}
|
|
455
|
+
/>
|
|
456
|
+
))}
|
|
457
|
+
</div>
|
|
458
|
+
) : null}
|
|
459
|
+
</div>
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The one 16px cell both lines hang their glyph in.
|
|
465
|
+
*
|
|
466
|
+
* A shared column rather than a leading character: the state and the engine mark
|
|
467
|
+
* are different widths and a bare glyph would let the two text columns start at
|
|
468
|
+
* different x. Centring inside a fixed cell is what makes them agree.
|
|
469
|
+
*/
|
|
470
|
+
function Gutter({ children }: { children: ReactNode }) {
|
|
471
|
+
return <span className='flex size-4 shrink-0 items-center justify-center'>{children}</span>
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Enter commits, Escape cancels, blur commits — the editor's inline-rename feel.
|
|
476
|
+
* An empty value is a deliberate "clear the name", which the gateway answers by
|
|
477
|
+
* handing back the derived title.
|
|
478
|
+
*
|
|
479
|
+
* The blur rule needs one guard. Selecting a session focuses a different surface
|
|
480
|
+
* — in the extension, a different VS Code view — so this document can lose focus
|
|
481
|
+
* a tick after a double-click opened the editor, and an unguarded blur reads
|
|
482
|
+
* that as "the user clicked away" and closes the editor in the frame it
|
|
483
|
+
* appeared. `document.hasFocus()` tells the two apart: focus moving WITHIN this
|
|
484
|
+
* document is the user leaving the field; the whole view losing focus is not.
|
|
485
|
+
* When the window comes back, so does the caret.
|
|
486
|
+
*/
|
|
487
|
+
function NameEditor({
|
|
488
|
+
initial,
|
|
489
|
+
onCommit,
|
|
490
|
+
onCancel,
|
|
491
|
+
}: {
|
|
492
|
+
initial: string
|
|
493
|
+
onCommit: (title: string) => void
|
|
494
|
+
onCancel: () => void
|
|
495
|
+
}) {
|
|
496
|
+
const [value, setValue] = useState(initial)
|
|
497
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
ref.current?.focus()
|
|
500
|
+
ref.current?.select()
|
|
501
|
+
}, [])
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
const onWindowFocus = () => {
|
|
504
|
+
ref.current?.focus()
|
|
505
|
+
ref.current?.select()
|
|
506
|
+
}
|
|
507
|
+
window.addEventListener('focus', onWindowFocus)
|
|
508
|
+
return () => window.removeEventListener('focus', onWindowFocus)
|
|
509
|
+
}, [])
|
|
510
|
+
return (
|
|
511
|
+
<input
|
|
512
|
+
ref={ref}
|
|
513
|
+
value={value}
|
|
514
|
+
spellCheck={false}
|
|
515
|
+
placeholder='Session name'
|
|
516
|
+
aria-label='Session name'
|
|
517
|
+
onClick={(e) => e.stopPropagation()}
|
|
518
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
519
|
+
onChange={(e) => setValue(e.target.value)}
|
|
520
|
+
onBlur={() => {
|
|
521
|
+
if (document.hasFocus()) onCommit(value.trim())
|
|
522
|
+
}}
|
|
523
|
+
onKeyDown={(e) => {
|
|
524
|
+
e.stopPropagation()
|
|
525
|
+
if (e.key === 'Enter') onCommit(value.trim())
|
|
526
|
+
else if (e.key === 'Escape') onCancel()
|
|
527
|
+
}}
|
|
528
|
+
/* The negative margin is load-bearing: the border (1px) and padding (1px)
|
|
529
|
+
make the input taller than the label it replaces, and the card would
|
|
530
|
+
grow by that much the moment a rename starts. Cancelling it exactly is
|
|
531
|
+
what keeps the list from shifting under the caret. */
|
|
532
|
+
className={cn(
|
|
533
|
+
'-my-0.5 min-w-0 flex-1 rounded-sm border border-ring bg-bg px-1 py-px',
|
|
534
|
+
'text-body-sm leading-5 text-fg-1 outline-none',
|
|
535
|
+
)}
|
|
536
|
+
/>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
@@ -11,9 +11,10 @@ export interface SessionListItemProps {
|
|
|
11
11
|
active?: boolean
|
|
12
12
|
onSelect?: (id: string) => void
|
|
13
13
|
onDelete?: (id: string) => void
|
|
14
|
+
className?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function SessionListItem({ session, active, onSelect, onDelete }: SessionListItemProps) {
|
|
17
|
+
export function SessionListItem({ session, active, onSelect, onDelete, className }: SessionListItemProps) {
|
|
17
18
|
const meta = STATUS_META[session.status]
|
|
18
19
|
return (
|
|
19
20
|
<div
|
|
@@ -22,6 +23,7 @@ export function SessionListItem({ session, active, onSelect, onDelete }: Session
|
|
|
22
23
|
className={cn(
|
|
23
24
|
'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
|
|
24
25
|
active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
|
|
26
|
+
className,
|
|
25
27
|
)}>
|
|
26
28
|
<button
|
|
27
29
|
type='button'
|