@workerdeck/ui 0.23.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -11,132 +11,22 @@ import type { Step } from './SessionSteps.tsx'
11
11
  import { cn } from '../../lib/utils.ts'
12
12
  import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
13
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
14
  export interface SessionItemProps {
81
15
  row: SessionRow
82
- /** This session is the one the host is showing. */
83
16
  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
17
  activeStepKey?: string
91
- /** Shown when the list is not already grouped by gateway. */
92
18
  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
19
  showProject?: boolean
103
- /** Resolved project-icon bytes by content hash. A hash this map has not got
104
- * yet simply draws no icon. */
105
20
  projectIcons?: Record<string, string>
106
- /** Uncontrolled by default — pass both to drive expansion from the host. */
107
21
  expanded?: boolean
108
22
  onExpandedChange?: (expanded: boolean) => void
109
23
  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
24
  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
25
  onRevealStep?: (toolUseId: string) => void
126
- /** Enables in-place rename. The trigger is the host's — see `renameOn`. */
127
26
  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
27
  renameOn?: 'doubleClick' | 'external'
135
28
  editing?: boolean
136
29
  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
30
  actions?: ReactNode
141
31
  className?: string
142
32
  }
@@ -161,9 +51,6 @@ export function SessionItem({
161
51
  className,
162
52
  }: SessionItemProps) {
163
53
  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
54
  const [ownExpanded, setOwnExpanded] = useState(false)
168
55
  const open = expanded ?? ownExpanded
169
56
  const setOpen = (next: boolean) => {
@@ -178,20 +65,9 @@ export function SessionItem({
178
65
  }
179
66
 
180
67
  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
68
  const project = showProject ? projectLabel(row) : projectSubpath(row)
184
69
  const projectIcon = showProject ? info.project?.icon : undefined
185
70
  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
71
  const cost = formatCost(info.totalCostUsd)
196
72
  const extras = [
197
73
  showGateway ? row.hostName : undefined,
@@ -199,85 +75,50 @@ export function SessionItem({
199
75
  cost === '—' ? undefined : cost,
200
76
  ].filter((part): part is string => Boolean(part))
201
77
 
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
78
  const model = friendlyModel(info.model)
214
79
  const parts: ReactNode[] = []
215
80
  if (model) {
216
81
  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)}>
82
+ <span key="model" className={vendorTextClass(engine, info.model)}>
220
83
  {model}
221
84
  </span>,
222
85
  )
223
86
  }
224
87
  if (project !== undefined) {
225
88
  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
- />
89
+ <span key="project">
90
+ <ProjectIcon icon={projectIcon} src={iconSrc} name={project} className="mr-1.5 size-4 align-[-0.3em]" />
236
91
  {project}
237
92
  </span>,
238
93
  )
239
94
  }
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.
95
+ for (const extra of extras) {
96
+ parts.push(<span key={extra}>{extra}</span>)
97
+ }
246
98
  const steps = sessionSteps(info, (toolUseId, kind) => {
247
- if (kind === 'agent') return onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.()
99
+ if (kind === 'agent') {
100
+ return onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.()
101
+ }
248
102
  return onRevealStep ? onRevealStep(toolUseId) : onSelect?.()
249
103
  })
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
104
  const holdsOpenAgent = steps.some((s) => s.kind === 'agent' && s.key === activeStepKey)
257
105
 
258
106
  return (
259
107
  <div
260
- data-slot='session-item'
108
+ data-slot="session-item"
261
109
  data-active={active || undefined}
262
- role='button'
110
+ role="button"
263
111
  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
112
  onClick={(e) => {
271
- if (e.detail > 1 || isEditing) return
113
+ if (e.detail > 1 || isEditing) {
114
+ return
115
+ }
272
116
  onSelect?.()
273
117
  }}
274
118
  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
119
+ if (e.target !== e.currentTarget) {
120
+ return
121
+ }
281
122
  if (e.key === 'Enter' || e.key === ' ') {
282
123
  e.preventDefault()
283
124
  onSelect?.()
@@ -286,43 +127,23 @@ export function SessionItem({
286
127
  className={cn(
287
128
  'group flex w-full cursor-pointer flex-col p-1 text-left outline-none',
288
129
  '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',
130
+ holdsOpenAgent ? 'bg-row-selected-weak' : active ? 'bg-row-selected' : 'hover:bg-row-hover',
301
131
  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'>
132
+ )}
133
+ >
134
+ <div className="flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5">
135
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
314
136
  <Gutter>
315
137
  <SessionStatusIcon row={row} />
316
138
  </Gutter>
317
139
  {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
140
  <NameEditor
322
141
  initial={info.title ?? ''}
323
142
  onCommit={(title) => {
324
143
  setEditing(false)
325
- if (title !== (info.title ?? '')) onRename(title)
144
+ if (title !== (info.title ?? '')) {
145
+ onRename(title)
146
+ }
326
147
  }}
327
148
  onCancel={() => setEditing(false)}
328
149
  />
@@ -336,81 +157,32 @@ export function SessionItem({
336
157
  }
337
158
  : undefined
338
159
  }
339
- className='min-w-0 flex-1 truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1'>
160
+ className="min-w-0 flex-1 truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
161
+ >
340
162
  {sessionLabel(info)}
341
163
  </span>
342
164
  )}
343
165
  {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
166
  <span
356
- title={`${row.unseen} new`}
167
+ // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
168
+ title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
357
169
  className={cn(
358
170
  'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
359
171
  '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
- )}>
172
+ row.state === 'working' || row.state === 'attention' ? 'bg-accent text-accent-fg' : 'bg-badge text-badge-fg',
173
+ )}
174
+ >
364
175
  {row.unseen}
365
176
  </span>
366
177
  ) : 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' />
178
+ <ContextRing usage={info.contextUsage} size={16} className="p-0.5" />
371
179
  </div>
372
180
 
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]'>
181
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
375
182
  <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
- />
183
+ <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
385
184
  </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. */}
185
+ <span className="min-w-0 truncate text-fg-4">
414
186
  {parts.map((part, i) => (
415
187
  <Fragment key={i}>
416
188
  {i > 0 ? ' · ' : ''}
@@ -418,19 +190,11 @@ export function SessionItem({
418
190
  </Fragment>
419
191
  ))}
420
192
  </span>
421
- <span className='shrink-0 text-fg-4'>
193
+ <span className="shrink-0 text-fg-4">
422
194
  {parts.length > 0 ? '· ' : ''}
423
195
  {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
424
196
  </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' />
197
+ <span className="min-w-0 flex-1" />
434
198
  {steps.length > 0 ? (
435
199
  <StepToggle
436
200
  expanded={open}
@@ -445,14 +209,9 @@ export function SessionItem({
445
209
  </div>
446
210
 
447
211
  {open && steps.length > 0 ? (
448
- <div className='flex flex-col'>
212
+ <div className="flex flex-col">
449
213
  {steps.map((step: Step) => (
450
- <StepRow
451
- key={step.key}
452
- step={step}
453
- active={step.key === activeStepKey}
454
- onSelect={step.onSelect}
455
- />
214
+ <StepRow key={step.key} step={step} active={step.key === activeStepKey} onSelect={step.onSelect} />
456
215
  ))}
457
216
  </div>
458
217
  ) : null}
@@ -460,39 +219,11 @@ export function SessionItem({
460
219
  )
461
220
  }
462
221
 
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
222
  function Gutter({ children }: { children: ReactNode }) {
471
- return <span className='flex size-4 shrink-0 items-center justify-center'>{children}</span>
223
+ return <span className="flex size-4 shrink-0 items-center justify-center">{children}</span>
472
224
  }
473
225
 
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
- }) {
226
+ function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit: (title: string) => void; onCancel: () => void }) {
496
227
  const [value, setValue] = useState(initial)
497
228
  const ref = useRef<HTMLInputElement>(null)
498
229
  useEffect(() => {
@@ -512,23 +243,25 @@ function NameEditor({
512
243
  ref={ref}
513
244
  value={value}
514
245
  spellCheck={false}
515
- placeholder='Session name'
516
- aria-label='Session name'
246
+ placeholder="Session name"
247
+ aria-label="Session name"
517
248
  onClick={(e) => e.stopPropagation()}
518
249
  onDoubleClick={(e) => e.stopPropagation()}
519
250
  onChange={(e) => setValue(e.target.value)}
520
251
  onBlur={() => {
521
- if (document.hasFocus()) onCommit(value.trim())
252
+ // Guarded on `document.hasFocus()`: selecting a session focuses another surface (in the extension, another view), and an unguarded blur closes the editor in the frame it appeared.
253
+ if (document.hasFocus()) {
254
+ onCommit(value.trim())
255
+ }
522
256
  }}
523
257
  onKeyDown={(e) => {
524
258
  e.stopPropagation()
525
- if (e.key === 'Enter') onCommit(value.trim())
526
- else if (e.key === 'Escape') onCancel()
259
+ if (e.key === 'Enter') {
260
+ onCommit(value.trim())
261
+ } else if (e.key === 'Escape') {
262
+ onCancel()
263
+ }
527
264
  }}
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
265
  className={cn(
533
266
  '-my-0.5 min-w-0 flex-1 rounded-sm border border-ring bg-bg px-1 py-px',
534
267
  'text-body-sm leading-5 text-fg-1 outline-none',
@@ -18,40 +18,37 @@ export function SessionListItem({ session, active, onSelect, onDelete, className
18
18
  const meta = STATUS_META[session.status]
19
19
  return (
20
20
  <div
21
- data-slot='session-list-item'
21
+ data-slot="session-list-item"
22
22
  data-active={active || undefined}
23
23
  className={cn(
24
24
  'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
25
25
  active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
26
26
  className,
27
- )}>
28
- <button
29
- type='button'
30
- onClick={() => onSelect?.(session.id)}
31
- className='min-w-0 flex-1 text-left outline-none'>
32
- <div className='flex items-center gap-2'>
33
- <span className='truncate text-body-sm font-medium text-fg-1'>
34
- {session.title ?? session.id.slice(0, 8)}
35
- </span>
36
- <Badge variant={meta.variant} dot className='shrink-0'>
27
+ )}
28
+ >
29
+ <button type="button" onClick={() => onSelect?.(session.id)} className="min-w-0 flex-1 text-left outline-none">
30
+ <div className="flex items-center gap-2">
31
+ <span className="truncate text-body-sm font-medium text-fg-1">{session.title ?? session.id.slice(0, 8)}</span>
32
+ <Badge variant={meta.variant} dot className="shrink-0">
37
33
  {meta.label}
38
34
  </Badge>
39
35
  </div>
40
- <div className='mt-0.5 flex items-center gap-2 font-mono text-label text-fg-4'>
41
- <span className='truncate'>{session.cwd}</span>
42
- {session.profile ? <span className='shrink-0'>@{session.profile}</span> : null}
43
- <span className='shrink-0'>{formatCost(session.totalCostUsd)}</span>
44
- <span className='shrink-0'>{formatRelativeTime(session.lastActivityAt ?? session.createdAt)}</span>
36
+ <div className="mt-0.5 flex items-center gap-2 font-mono text-label text-fg-4">
37
+ <span className="truncate">{session.cwd}</span>
38
+ {session.profile ? <span className="shrink-0">@{session.profile}</span> : null}
39
+ <span className="shrink-0">{formatCost(session.totalCostUsd)}</span>
40
+ <span className="shrink-0">{formatRelativeTime(session.lastActivityAt ?? session.createdAt)}</span>
45
41
  </div>
46
42
  </button>
47
43
  {onDelete ? (
48
44
  <Button
49
- variant='ghost'
50
- size='icon-sm'
51
- aria-label='Close session'
52
- className='opacity-0 transition-opacity group-hover:opacity-100'
53
- onClick={() => onDelete(session.id)}>
54
- <Trash2 className='size-3.5 text-fg-3' />
45
+ variant="ghost"
46
+ size="icon-sm"
47
+ aria-label="Close session"
48
+ className="opacity-0 transition-opacity group-hover:opacity-100"
49
+ onClick={() => onDelete(session.id)}
50
+ >
51
+ <Trash2 className="size-3.5 text-fg-3" />
55
52
  </Button>
56
53
  ) : null}
57
54
  </div>
@@ -67,27 +64,14 @@ export interface SessionListProps {
67
64
  className?: string
68
65
  }
69
66
 
70
- export function SessionList({
71
- sessions,
72
- activeId,
73
- onSelect,
74
- onDelete,
75
- emptyText = 'No sessions yet.',
76
- className,
77
- }: SessionListProps) {
67
+ export function SessionList({ sessions, activeId, onSelect, onDelete, emptyText = 'No sessions yet.', className }: SessionListProps) {
78
68
  return (
79
- <div data-slot='session-list' className={cn('flex flex-col gap-1', className)}>
69
+ <div data-slot="session-list" className={cn('flex flex-col gap-1', className)}>
80
70
  {sessions.length === 0 ? (
81
- <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{emptyText}</div>
71
+ <div className="px-2.5 py-6 text-center text-body-sm text-fg-4">{emptyText}</div>
82
72
  ) : (
83
73
  sessions.map((session) => (
84
- <SessionListItem
85
- key={session.id}
86
- session={session}
87
- active={session.id === activeId}
88
- onSelect={onSelect}
89
- onDelete={onDelete}
90
- />
74
+ <SessionListItem key={session.id} session={session} active={session.id === activeId} onSelect={onSelect} onDelete={onDelete} />
91
75
  ))
92
76
  )}
93
77
  </div>