@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.
- package/build/{SessionPanel-BVbau2eR.d.mts → SessionPanel-CJwp5B3q.d.mts} +8 -3
- package/build/index.d.mts +39 -20
- package/build/index.mjs +119 -52
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +60 -11
- package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-CepFsMdW.mjs} +541 -254
- package/build/use-path-links-CepFsMdW.mjs.map +1 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +28 -5
- package/src/components/agent/PromptTokenText.tsx +9 -1
- package/src/components/agent/SessionBrowser.tsx +17 -1
- package/src/components/agent/SessionItem.tsx +10 -26
- package/src/components/agent/SessionPanel.tsx +12 -0
- package/src/components/agent/SessionSteps.tsx +15 -55
- package/src/components/agent/SubagentToggle.tsx +69 -0
- package/src/components/agent/Transcript.tsx +91 -80
- package/src/components/agent/TranscriptItemView.tsx +2 -0
- package/src/components/agent/TranscriptRows.tsx +60 -56
- package/src/components/agent/UsageDialog.tsx +19 -6
- package/src/components/agent/composer-commands.ts +18 -0
- package/src/components/agent/pulse.tsx +53 -16
- package/src/components/agent/use-transcript-jumps.ts +25 -0
- package/src/components/terminal/TerminalTranscript.tsx +31 -27
- package/src/components/terminal/blocks.ts +15 -7
- package/src/components/terminal/height.ts +14 -5
- package/src/components/terminal/items.tsx +97 -11
- package/src/components/terminal/peer-names.tsx +16 -0
- package/src/components/terminal/row.tsx +1 -1
- package/src/components/terminal/tool-run.ts +68 -0
- package/src/index.ts +10 -2
- package/src/lib/session-cost.ts +12 -2
- package/src/lib/tool-icon.ts +5 -1
- package/src/styles/terminal.css +102 -1
- package/src/styles/theme.css +1 -1
- 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
|
-
<
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
emptyState
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
</
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
<
|
|
371
|
+
<div
|
|
353
372
|
key={row.key}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = ['
|
|
4
|
-
export const PULSE_MS =
|
|
3
|
+
export const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
|
|
4
|
+
export const PULSE_MS = 90
|
|
5
5
|
|
|
6
|
-
export const PULSE_REST =
|
|
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
|
-
|
|
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
|