@workerdeck/ui 2.10.0 → 2.12.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 (37) hide show
  1. package/build/{SessionPanel-BVbau2eR.d.mts → SessionPanel-CJwp5B3q.d.mts} +8 -3
  2. package/build/index.d.mts +39 -20
  3. package/build/index.mjs +119 -52
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +60 -11
  6. package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-CepFsMdW.mjs} +541 -254
  7. package/build/use-path-links-CepFsMdW.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/Composer.tsx +28 -5
  12. package/src/components/agent/PromptTokenText.tsx +9 -1
  13. package/src/components/agent/SessionBrowser.tsx +17 -1
  14. package/src/components/agent/SessionItem.tsx +10 -26
  15. package/src/components/agent/SessionPanel.tsx +12 -0
  16. package/src/components/agent/SessionSteps.tsx +15 -55
  17. package/src/components/agent/SubagentToggle.tsx +69 -0
  18. package/src/components/agent/Transcript.tsx +91 -80
  19. package/src/components/agent/TranscriptItemView.tsx +2 -0
  20. package/src/components/agent/TranscriptRows.tsx +60 -56
  21. package/src/components/agent/UsageDialog.tsx +19 -6
  22. package/src/components/agent/composer-commands.ts +18 -0
  23. package/src/components/agent/pulse.tsx +53 -16
  24. package/src/components/agent/use-transcript-jumps.ts +25 -0
  25. package/src/components/terminal/TerminalTranscript.tsx +31 -27
  26. package/src/components/terminal/blocks.ts +15 -7
  27. package/src/components/terminal/height.ts +14 -5
  28. package/src/components/terminal/items.tsx +97 -11
  29. package/src/components/terminal/peer-names.tsx +16 -0
  30. package/src/components/terminal/row.tsx +1 -1
  31. package/src/components/terminal/tool-run.ts +68 -0
  32. package/src/index.ts +10 -2
  33. package/src/lib/session-cost.ts +12 -2
  34. package/src/lib/tool-icon.ts +5 -1
  35. package/src/styles/terminal.css +102 -1
  36. package/src/styles/theme.css +1 -1
  37. package/build/use-path-links-CvcxdUXL.mjs.map +0 -1
@@ -10,7 +10,7 @@ import { subagentItems, terminalBlocks, type ToolCallItem } from '../terminal/bl
10
10
  import { taskBrief, taskBusy } from '../terminal/tool-run.ts'
11
11
  import { type TranscriptRow } from './transcript-rows.ts'
12
12
  import { TranscriptRows } from './TranscriptRows.tsx'
13
- import { SkillNamesProvider } from './PromptTokenText.tsx'
13
+ import { SessionNamesProvider, SkillNamesProvider } from './PromptTokenText.tsx'
14
14
  import { TerminalSurface } from '../terminal/surface.tsx'
15
15
  import { ROW_GAP, TranscriptVariantProvider, type TranscriptDensity, type TranscriptVariant } from './transcript-variant.tsx'
16
16
 
@@ -79,6 +79,8 @@ export interface TranscriptProps {
79
79
  stickyPrompt?: boolean
80
80
  scrubber?: boolean
81
81
  bookmarks?: readonly string[]
82
+ // Folded peer slugs, so a `#Name` this client can name draws as a token.
83
+ sessionNames?: readonly string[]
82
84
  replaying?: boolean
83
85
  catchUp?: { from: number; since?: number }
84
86
  jumpToRecapRef?: RefObject<(() => void) | null>
@@ -104,6 +106,7 @@ export function Transcript({
104
106
  stickyPrompt = false,
105
107
  scrubber,
106
108
  bookmarks,
109
+ sessionNames,
107
110
  replaying = false,
108
111
  catchUp,
109
112
  jumpToRecapRef,
@@ -141,88 +144,96 @@ export function Transcript({
141
144
  return (
142
145
  <TranscriptVariantProvider value={variant}>
143
146
  <SkillNamesProvider names={skillNames}>
144
- <Conversation className={cn(replaying && 'invisible', className)}>
145
- <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
146
- <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
147
- {frame ? (
148
- frameTask === undefined && !replaying ? (
149
- <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
150
- This sub-agent's work is not in this transcript.
151
- </div>
152
- ) : null
153
- ) : items.length === 0 && state.status !== 'starting' ? (
154
- emptyState !== undefined ? (
155
- emptyState
156
- ) : (
157
- <SessionEmptyState
158
- cwd={state.cwd}
159
- hasCommands={!!state.commands?.length}
160
- hasSkills={!!state.skills?.some((s) => s.enabled)}
161
- canBrowseFiles={canBrowseFiles}
162
- />
163
- )
164
- ) : null}
165
- {frame && frameTask === undefined && !replaying ? null : (
166
- <TranscriptRows
167
- rows={rows}
168
- boundary={boundary}
169
- since={catchUp?.since}
170
- terminal={terminal}
171
- replaying={replaying}
172
- stickyPrompt={!frame && stickyPrompt}
173
- gap={gap}
174
- fontSize={fontSize}
175
- lineHeight={lineHeight}
176
- items={items}
177
- pendingApprovals={state.pendingApprovals}
178
- scrubber={scrubber}
179
- bookmarks={frame ? undefined : bookmarks}
180
- affordances={affordances}
181
- fileUrl={fileUrl}
182
- attachmentUrl={attachmentUrl}
183
- hostImage={hostImage}
184
- jumpToRecapRef={jumpToRecapRef}
185
- repinRef={repinRef}
186
- reveal={frame ? undefined : reveal}
187
- frameParentId={frame?.parentToolUseId}
188
- onOpenSubagent={frame ? undefined : onOpenSubagent}
189
- />
190
- )}
191
- {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
192
- terminal ? (
193
- <>
194
- {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
195
- <WorkingRow
196
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
197
- startedAt={runStartedAt}
198
- tokens={state.contextUsage?.totalTokens}
147
+ <SessionNamesProvider names={sessionNames}>
148
+ <Conversation className={cn(replaying && 'invisible', className)}>
149
+ <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
150
+ <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
151
+ {frame ? (
152
+ frameTask === undefined && !replaying ? (
153
+ <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
154
+ This sub-agent's work is not in this transcript.
155
+ </div>
156
+ ) : null
157
+ ) : items.length === 0 && state.status !== 'starting' ? (
158
+ emptyState !== undefined ? (
159
+ emptyState
160
+ ) : (
161
+ <SessionEmptyState
162
+ cwd={state.cwd}
163
+ hasCommands={!!state.commands?.length}
164
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
165
+ canBrowseFiles={canBrowseFiles}
199
166
  />
200
- </>
201
- ) : (
202
- <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
203
- )
204
- ) : null}
205
- </TerminalShell>
206
- </ConversationContent>
207
- <ConversationScrollButton />
208
- {replaying ? (
209
- <div
210
- data-slot="transcript-hold"
211
- aria-hidden
212
- className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
213
- >
214
- <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
215
- {terminal ? (
216
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
217
- <WorkingRow label="Loading…" />
218
- </TerminalSurface>
219
- ) : (
220
- <Loader label="Loading session…" />
167
+ )
168
+ ) : null}
169
+ {frame && frameTask === undefined && !replaying ? null : (
170
+ <TranscriptRows
171
+ rows={rows}
172
+ boundary={boundary}
173
+ since={catchUp?.since}
174
+ terminal={terminal}
175
+ replaying={replaying}
176
+ stickyPrompt={!frame && stickyPrompt}
177
+ gap={gap}
178
+ fontSize={fontSize}
179
+ lineHeight={lineHeight}
180
+ items={items}
181
+ pendingApprovals={state.pendingApprovals}
182
+ scrubber={scrubber}
183
+ bookmarks={frame ? undefined : bookmarks}
184
+ affordances={affordances}
185
+ fileUrl={fileUrl}
186
+ attachmentUrl={attachmentUrl}
187
+ hostImage={hostImage}
188
+ jumpToRecapRef={jumpToRecapRef}
189
+ repinRef={repinRef}
190
+ reveal={frame ? undefined : reveal}
191
+ frameParentId={frame?.parentToolUseId}
192
+ onOpenSubagent={frame ? undefined : onOpenSubagent}
193
+ />
221
194
  )}
195
+ {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
196
+ terminal ? (
197
+ <>
198
+ {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
199
+ <WorkingRow
200
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
201
+ startedAt={runStartedAt}
202
+ tokens={state.contextUsage?.totalTokens}
203
+ />
204
+ </>
205
+ ) : (
206
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
207
+ )
208
+ ) : null}
209
+ </TerminalShell>
210
+ </ConversationContent>
211
+ <ConversationScrollButton />
212
+ {replaying ? (
213
+ <div
214
+ data-slot="transcript-hold"
215
+ aria-hidden
216
+ className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
217
+ >
218
+ <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
219
+ {terminal ? (
220
+ <TerminalSurface
221
+ fontSize={fontSize}
222
+ lineHeight={lineHeight}
223
+ affordances={false}
224
+ bleed="1ch"
225
+ className="term-transcript"
226
+ >
227
+ <WorkingRow label="Loading…" />
228
+ </TerminalSurface>
229
+ ) : (
230
+ <Loader label="Loading session…" />
231
+ )}
232
+ </div>
222
233
  </div>
223
- </div>
224
- ) : null}
225
- </Conversation>
234
+ ) : null}
235
+ </Conversation>
236
+ </SessionNamesProvider>
226
237
  </SkillNamesProvider>
227
238
  </TranscriptVariantProvider>
228
239
  )
@@ -10,6 +10,7 @@ import { Response } from './Response.tsx'
10
10
  import { ToolCallCard } from './ToolCallCard.tsx'
11
11
  import { Row } from '../terminal/row.tsx'
12
12
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
13
+ import { peerLabel } from '../terminal/items.tsx'
13
14
 
14
15
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
15
16
  return (
@@ -69,6 +70,7 @@ export function TranscriptItemView({
69
70
  case 'user': {
70
71
  return (
71
72
  <Message from="user">
73
+ {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
72
74
  {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
73
75
  {item.text ? (
74
76
  <MessageContent>
@@ -6,14 +6,15 @@ import type { PermissionRequest } from '@workerdeck/protocol'
6
6
  import type { TranscriptItem } from '@workerdeck/react'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
9
- import { ToolRunRow } from '../terminal/items.tsx'
9
+ import { RunRow } from '../terminal/items.tsx'
10
10
  import { parentOf } from '../terminal/blocks.ts'
11
11
  import { briefPx, estimateBlockPx } from '../terminal/height.ts'
12
+ import { PeerNamesProvider } from '../terminal/peer-names.tsx'
12
13
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
13
14
  import { Scrubber } from './Scrubber.tsx'
14
15
  import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
15
16
  import { useHeightEpoch } from './use-height-epoch.ts'
16
- import { useTranscriptJumps } from './use-transcript-jumps.ts'
17
+ import { absorbScrollerResize, useTranscriptJumps } from './use-transcript-jumps.ts'
17
18
  import { BriefRow, TaskRow } from '../terminal/TerminalTranscript.tsx'
18
19
  import { RecapRow, TranscriptItemView } from './TranscriptItemView.tsx'
19
20
 
@@ -167,9 +168,10 @@ export function TranscriptRows({
167
168
  if (height === last) {
168
169
  return
169
170
  }
171
+ const difference = height - last
170
172
  last = height
171
173
  if (stick.state.isAtBottom) {
172
- void stick.scrollToBottom('instant')
174
+ absorbScrollerResize(stick, difference)
173
175
  }
174
176
  })
175
177
  observer.observe(scrollElement)
@@ -318,64 +320,66 @@ export function TranscriptRows({
318
320
 
319
321
  return (
320
322
  <div ref={rowsRef} data-slot="transcript-rows" className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
321
- {virtualizer.getVirtualItems().map((virtualRow) => {
322
- const row = rows[virtualRow.index]
323
- const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
324
- const content =
325
- 'run' in row ? (
326
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
327
- <ToolRunRow items={row.run} />
328
- </div>
329
- ) : 'item' in row ? (
330
- <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
331
- <TranscriptItemView
332
- item={row.item}
333
- fileUrl={fileUrl}
334
- attachmentUrl={attachmentUrl}
335
- hostImage={hostImage}
323
+ <PeerNamesProvider items={items}>
324
+ {virtualizer.getVirtualItems().map((virtualRow) => {
325
+ const row = rows[virtualRow.index]
326
+ const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
327
+ const content =
328
+ 'run' in row ? (
329
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
330
+ <RunRow items={row.run} />
331
+ </div>
332
+ ) : 'item' in row ? (
333
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
334
+ <TranscriptItemView
335
+ item={row.item}
336
+ fileUrl={fileUrl}
337
+ attachmentUrl={attachmentUrl}
338
+ hostImage={hostImage}
339
+ terminal={terminal}
340
+ />
341
+ </div>
342
+ ) : 'text' in row && row.key === 'brief' ? (
343
+ <BriefRow text={row.text} terminal={terminal} />
344
+ ) : 'line' in row ? (
345
+ <RecapRow line={row.line} since={since} terminal={terminal} />
346
+ ) : (
347
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
348
+ <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
349
+ </div>
350
+ )
351
+ if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
352
+ const next = promptRows.find((index) => index > virtualRow.index)
353
+ const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
354
+ return (
355
+ <StickyPromptLane
356
+ key={row.key}
357
+ top={virtualRow.start}
358
+ height={Math.max(laneEnd - virtualRow.start, 0)}
359
+ gapClass={gapClass}
360
+ gapPx={gap.px}
336
361
  terminal={terminal}
362
+ scrollRoot={scrollElement}
363
+ index={virtualRow.index}
364
+ measureRef={virtualizer.measureElement}
365
+ head={terminal ? content : promptHeadText(row.item)}
366
+ content={content}
337
367
  />
338
- </div>
339
- ) : 'text' in row && row.key === 'brief' ? (
340
- <BriefRow text={row.text} terminal={terminal} />
341
- ) : 'line' in row ? (
342
- <RecapRow line={row.line} since={since} terminal={terminal} />
343
- ) : (
344
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
345
- <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
346
- </div>
347
- )
348
- if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
349
- const next = promptRows.find((index) => index > virtualRow.index)
350
- const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
368
+ )
369
+ }
351
370
  return (
352
- <StickyPromptLane
371
+ <div
353
372
  key={row.key}
354
- top={virtualRow.start}
355
- height={Math.max(laneEnd - virtualRow.start, 0)}
356
- gapClass={gapClass}
357
- gapPx={gap.px}
358
- terminal={terminal}
359
- scrollRoot={scrollElement}
360
- index={virtualRow.index}
361
- measureRef={virtualizer.measureElement}
362
- head={terminal ? content : promptHeadText(row.item)}
363
- content={content}
364
- />
373
+ ref={virtualizer.measureElement}
374
+ data-index={virtualRow.index}
375
+ className={cn('absolute inset-x-0 top-0', gapClass)}
376
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
377
+ >
378
+ {content}
379
+ </div>
365
380
  )
366
- }
367
- return (
368
- <div
369
- key={row.key}
370
- ref={virtualizer.measureElement}
371
- data-index={virtualRow.index}
372
- className={cn('absolute inset-x-0 top-0', gapClass)}
373
- style={{ transform: `translateY(${virtualRow.start}px)` }}
374
- >
375
- {content}
376
- </div>
377
- )
378
- })}
381
+ })}
382
+ </PeerNamesProvider>
379
383
  {scrubber && scrollElement?.parentElement
380
384
  ? createPortal(
381
385
  terminal ? (
@@ -1,5 +1,5 @@
1
- import type { ByModel, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
2
- import { PRICING_NOTE } from '@workerdeck/protocol'
1
+ import type { ByModel, PricingTable, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
2
+ import { PRICING_NOTE, pricingAgeNote } from '@workerdeck/protocol'
3
3
  import { Badge } from '../ui/Badge.tsx'
4
4
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
5
5
  import { formatAgoPrecise, formatCost, formatTokens } from '../../lib/format.ts'
@@ -13,6 +13,7 @@ export interface UsageDialogProps {
13
13
  totalCostUsd?: number
14
14
  costUsd?: number
15
15
  usageByModel?: ByModel
16
+ pricing?: PricingTable
16
17
  spend?: ProfileSpend
17
18
  updatedAt?: number
18
19
  open: boolean
@@ -20,6 +21,10 @@ export interface UsageDialogProps {
20
21
  className?: string
21
22
  }
22
23
 
24
+ function engineLabel(engine: ProfileEngine): string {
25
+ return engine === 'claude' ? 'Claude Code' : engine
26
+ }
27
+
23
28
  function Line({ label, value, tone }: { label: string; value: string; tone?: 'muted' }) {
24
29
  return (
25
30
  <div className="flex items-baseline justify-between gap-4">
@@ -62,9 +67,15 @@ function CostEvidence({ cost, engine }: { cost: SessionCost; engine: ProfileEngi
62
67
  rather than an estimate.
63
68
  </p>
64
69
  ) : null}
70
+ {cost.unknownBasisModels.length > 0 ? (
71
+ <p className="text-label text-warning">
72
+ {engineLabel(engine)} matched no rate table of its own to {cost.unknownBasisModels.join(', ')}, so the figure it reports for those
73
+ tokens is a guess rather than a rate.
74
+ </p>
75
+ ) : null}
65
76
  {cost.gapUsd !== undefined ? (
66
77
  <p className="text-label text-fg-4">
67
- {engine === 'claude' ? 'Claude Code' : engine} reports {formatCost(cost.reportedUsd)} for the same tokens.
78
+ {engineLabel(engine)} reports {formatCost(cost.reportedUsd)} for the same tokens.
68
79
  </p>
69
80
  ) : null}
70
81
  </div>
@@ -98,6 +109,7 @@ export function UsageDialog({
98
109
  totalCostUsd,
99
110
  costUsd,
100
111
  usageByModel,
112
+ pricing,
101
113
  spend,
102
114
  updatedAt,
103
115
  open,
@@ -105,13 +117,14 @@ export function UsageDialog({
105
117
  className,
106
118
  }: UsageDialogProps) {
107
119
  const now = useMinuteClock(open)
108
- const cost = sessionCost({ costUsd, totalCostUsd, usageByModel })
120
+ const cost = sessionCost({ costUsd, totalCostUsd, usageByModel, pricing })
121
+ const ageNote = pricingAgeNote(now)
109
122
  return (
110
123
  <Dialog open={open} onOpenChange={onOpenChange}>
111
124
  <DialogContent className={className}>
112
125
  <DialogHeader
113
126
  title="Usage"
114
- description={engine === 'claude' ? 'Claude Code' : engine}
127
+ description={engineLabel(engine)}
115
128
  actions={
116
129
  subscriptionType ? (
117
130
  <Badge variant="accent" className="mt-0.5 shrink-0 capitalize">
@@ -134,7 +147,7 @@ export function UsageDialog({
134
147
  <CostEvidence cost={cost} engine={engine} />
135
148
  </div>
136
149
  {spend ? <ProfileSpendSection spend={spend} /> : null}
137
- <p className="mt-3 text-label text-fg-4">{PRICING_NOTE}</p>
150
+ <p className="mt-3 text-label text-fg-4">{ageNote ? `${PRICING_NOTE} ${ageNote}` : PRICING_NOTE}</p>
138
151
  {updatedAt ? <p className="mt-2 text-label text-fg-4">Updated {formatAgoPrecise(updatedAt, now)}</p> : null}
139
152
  </DialogBody>
140
153
  </DialogContent>
@@ -109,6 +109,24 @@ export function rankComposerRows(query: string, rows: readonly ComposerRow[]): C
109
109
  return scored.map(({ row }) => row)
110
110
  }
111
111
 
112
+ // The `#` picker's ranking. Peers arrive most-recently-active first, and an empty query keeps that
113
+ // order: which session moved last is the most useful thing the list can say before anything is typed.
114
+ export function rankPeerSessions<T extends { slug: string; label: string }>(query: string, peers: readonly T[]): T[] {
115
+ const needle = query.trim()
116
+ if (!needle) {
117
+ return [...peers]
118
+ }
119
+ const scored: Array<{ score: number; index: number; peer: T }> = []
120
+ peers.forEach((peer, index) => {
121
+ const score = matchScore(needle, [peer.slug, peer.label, ...peer.slug.split('-')])
122
+ if (score > 0) {
123
+ scored.push({ score, index, peer })
124
+ }
125
+ })
126
+ scored.sort((a, b) => b.score - a.score || a.index - b.index)
127
+ return scored.map(({ peer }) => peer)
128
+ }
129
+
112
130
  export function matchClientCommand(text: string, commands: readonly ClientCommand[]): { command: ClientCommand; args: string } | undefined {
113
131
  const match = /^\/([\w:-]+)[ \t]*([\s\S]*)$/.exec(text.trim())
114
132
  if (!match) {
@@ -1,9 +1,57 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useState, useSyncExternalStore } from 'react'
2
2
 
3
- export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
4
- export const PULSE_MS = 150
3
+ export const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
4
+ export const PULSE_MS = 90
5
5
 
6
- export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
6
+ export const PULSE_REST = '⠿'
7
+
8
+ const listeners = new Set<() => void>()
9
+ let ticker: ReturnType<typeof setInterval> | undefined
10
+ let current = PULSE_REST
11
+
12
+ export function pulseFrameAt(now: number): string {
13
+ return PULSE_FRAMES[Math.floor(now / PULSE_MS) % PULSE_FRAMES.length]!
14
+ }
15
+
16
+ function tick(): void {
17
+ const next = pulseFrameAt(Date.now())
18
+ if (next === current) {
19
+ return
20
+ }
21
+ current = next
22
+ for (const listener of listeners) {
23
+ listener()
24
+ }
25
+ }
26
+
27
+ function subscribe(listener: () => void): () => void {
28
+ listeners.add(listener)
29
+ if (ticker === undefined) {
30
+ current = pulseFrameAt(Date.now())
31
+ ticker = setInterval(tick, PULSE_MS)
32
+ }
33
+ return () => {
34
+ listeners.delete(listener)
35
+ if (listeners.size === 0 && ticker !== undefined) {
36
+ clearInterval(ticker)
37
+ ticker = undefined
38
+ }
39
+ }
40
+ }
41
+
42
+ function subscribeRest(): () => void {
43
+ return unsubscribeRest
44
+ }
45
+
46
+ function unsubscribeRest(): void {}
47
+
48
+ function readFrame(): string {
49
+ return current
50
+ }
51
+
52
+ function readRest(): string {
53
+ return PULSE_REST
54
+ }
7
55
 
8
56
  export function usePrefersReducedMotion(): boolean {
9
57
  const [reduced, setReduced] = useState(false)
@@ -23,16 +71,5 @@ export function usePrefersReducedMotion(): boolean {
23
71
  export function usePulse(animated: boolean): string {
24
72
  const reduced = usePrefersReducedMotion()
25
73
  const running = animated && !reduced
26
- const [frame, setFrame] = useState(0)
27
- useEffect(() => {
28
- if (!running) {
29
- return
30
- }
31
- const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
32
- return () => clearInterval(timer)
33
- }, [running])
34
- if (!running) {
35
- return PULSE_REST
36
- }
37
- return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
74
+ return useSyncExternalStore(running ? subscribe : subscribeRest, running ? readFrame : readRest, readRest)
38
75
  }
@@ -26,6 +26,31 @@ export function repinToBottom(stick: RepinTarget): void {
26
26
  state.scrollTop = state.calculatedTargetScrollTop
27
27
  }
28
28
 
29
+ // A scroller whose height changes clamps `scrollTop`, and the browser reports that clamp as an
30
+ // ordinary scroll event, which the library reads as scroll-up intent and answers by escaping the
31
+ // bottom lock. Its guard against exactly that, `resizeDifference`, is set only by the observer on
32
+ // the *content*, so a composer collapsing after send breaks the lock while the view still sits at
33
+ // the bottom, and the turn's later output streams past unfollowed (GOTCHAS "A scroller resize
34
+ // escapes the bottom lock"). Set the flag ourselves, on the library's schedule. A live send re-pin
35
+ // owns `state.animation` and `scrollToBottom` would clear it, dropping the `ignoreEscapes` hold
36
+ // into the momentum tail, so press that scroll by hand instead.
37
+ export function absorbScrollerResize(stick: RepinTarget, difference: number): void {
38
+ const { state } = stick
39
+ state.resizeDifference = difference
40
+ requestAnimationFrame(() => {
41
+ setTimeout(() => {
42
+ if (state.resizeDifference === difference) {
43
+ state.resizeDifference = 0
44
+ }
45
+ }, 1)
46
+ })
47
+ if (state.animation?.ignoreEscapes) {
48
+ state.scrollTop = state.calculatedTargetScrollTop
49
+ return
50
+ }
51
+ void stick.scrollToBottom('instant')
52
+ }
53
+
29
54
  export function useTranscriptJumps(options: {
30
55
  rows: TranscriptRow[]
31
56
  terminal: boolean