@workerdeck/ui 2.10.0 → 2.11.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/index.d.mts +4 -3
- package/build/index.mjs +12 -8
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +56 -1
- package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-DO_79-wX.mjs} +405 -176
- package/build/use-path-links-DO_79-wX.mjs.map +1 -0
- package/build/workspace.mjs +1 -1
- package/package.json +4 -4
- package/src/components/agent/SessionItem.tsx +4 -3
- package/src/components/agent/SessionPanel.tsx +2 -0
- 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/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/lib/session-cost.ts +12 -2
- package/src/lib/tool-icon.ts +5 -1
- package/src/styles/terminal.css +93 -0
- package/src/styles/theme.css +1 -1
- package/build/use-path-links-CvcxdUXL.mjs.map +0 -1
package/build/workspace.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { At as Spinner, B as EntryIcon, Ft as TooltipProvider, Nt as Tip, Ot as Splitter, an as Button, i as SessionPanel, n as usePathLinks, nn as Input, sn as cn, t as resolveAgainstCwd } from "./use-path-links-
|
|
1
|
+
import { At as Spinner, B as EntryIcon, Ft as TooltipProvider, Nt as Tip, Ot as Splitter, an as Button, i as SessionPanel, n as usePathLinks, nn as Input, sn as cn, t as resolveAgainstCwd } from "./use-path-links-DO_79-wX.mjs";
|
|
2
2
|
import { m as formatBytes } from "./status-kmsBhuFG.mjs";
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workerdeck/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"streamdown": "^2.6.0",
|
|
40
40
|
"tailwind-merge": "^3.7.0",
|
|
41
41
|
"use-stick-to-bottom": "^1.1.6",
|
|
42
|
-
"@workerdeck/client": "2.
|
|
43
|
-
"@workerdeck/
|
|
44
|
-
"@workerdeck/
|
|
42
|
+
"@workerdeck/client": "2.11.0",
|
|
43
|
+
"@workerdeck/protocol": "2.11.0",
|
|
44
|
+
"@workerdeck/react": "2.11.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"monaco-editor": "^0.56.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
|
-
import { projectLabel, projectSubpath, sessionLabel } from '@workerdeck/protocol'
|
|
3
|
+
import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
|
|
4
4
|
import type { SessionRow } from '@workerdeck/protocol'
|
|
5
5
|
import { ContextRing } from './ContextRing.tsx'
|
|
6
6
|
import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
|
|
@@ -68,6 +68,7 @@ export function SessionItem({
|
|
|
68
68
|
|
|
69
69
|
const engine = info.engine ?? 'claude'
|
|
70
70
|
const project = showProject ? projectLabel(row) : projectSubpath(row)
|
|
71
|
+
const projectTitle = showProject ? projectName(row) : undefined
|
|
71
72
|
const projectIcon = showProject ? info.project?.icon : undefined
|
|
72
73
|
const iconSrc = projectIcon?.type === 'image' ? projectIcons?.[projectIcon.hash] : undefined
|
|
73
74
|
const cost = formatCost(info.costUsd ?? info.totalCostUsd)
|
|
@@ -88,8 +89,8 @@ export function SessionItem({
|
|
|
88
89
|
}
|
|
89
90
|
if (project !== undefined) {
|
|
90
91
|
parts.push(
|
|
91
|
-
<span key="project">
|
|
92
|
-
<ProjectIcon icon={projectIcon} src={iconSrc} name={
|
|
92
|
+
<span key="project" title={projectTitle}>
|
|
93
|
+
<ProjectIcon icon={projectIcon} src={iconSrc} name={projectTitle} className="mr-1.5 size-4 align-[-0.3em]" />
|
|
93
94
|
{project}
|
|
94
95
|
</span>,
|
|
95
96
|
)
|
|
@@ -264,6 +264,7 @@ export function SessionPanel({
|
|
|
264
264
|
replaying,
|
|
265
265
|
protocolMismatch,
|
|
266
266
|
shell,
|
|
267
|
+
pricing,
|
|
267
268
|
models,
|
|
268
269
|
effectiveModel,
|
|
269
270
|
handle,
|
|
@@ -835,6 +836,7 @@ export function SessionPanel({
|
|
|
835
836
|
totalCostUsd={state.totalCostUsd}
|
|
836
837
|
costUsd={state.costUsd}
|
|
837
838
|
usageByModel={state.usageByModel}
|
|
839
|
+
pricing={pricing}
|
|
838
840
|
spend={profileSpend}
|
|
839
841
|
updatedAt={usageUpdatedAt}
|
|
840
842
|
open={panel === 'usage'}
|
|
@@ -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>
|
|
@@ -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
|
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
CompactionRow,
|
|
9
9
|
FileRow,
|
|
10
10
|
NoticeRow,
|
|
11
|
-
|
|
11
|
+
PeerSendRow,
|
|
12
|
+
RunRow,
|
|
12
13
|
ThinkingRow,
|
|
13
14
|
ToolRow,
|
|
14
15
|
TurnResultRow,
|
|
@@ -17,8 +18,9 @@ import {
|
|
|
17
18
|
} from './items.tsx'
|
|
18
19
|
import { blockNeedsBlank, taskChildItems, terminalBlocks, type TaskBlock } from './blocks.ts'
|
|
19
20
|
import { usePulse } from '../agent/pulse.tsx'
|
|
21
|
+
import { PeerNamesProvider } from './peer-names.tsx'
|
|
20
22
|
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
21
|
-
import { taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
|
|
23
|
+
import { isPeerSend, taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
|
|
22
24
|
import { BRIEF_LINES } from './height.ts'
|
|
23
25
|
import { Blank, Row } from './row.tsx'
|
|
24
26
|
import { TerminalSurface } from './surface.tsx'
|
|
@@ -44,7 +46,7 @@ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; file
|
|
|
44
46
|
return <ThinkingRow item={item} />
|
|
45
47
|
}
|
|
46
48
|
case 'tool_call': {
|
|
47
|
-
return <ToolRow item={item} />
|
|
49
|
+
return isPeerSend(item) ? <PeerSendRow item={item} /> : <ToolRow item={item} />
|
|
48
50
|
}
|
|
49
51
|
case 'turn_result': {
|
|
50
52
|
return <TurnResultRow item={item} />
|
|
@@ -110,7 +112,7 @@ export function TaskRow({
|
|
|
110
112
|
<div ref={reveal} className={open ? 'term-open' : undefined}>
|
|
111
113
|
<Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
|
|
112
114
|
<Row glyph={busy ? pulse : '●'} glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'} tone={failed ? 'red' : 'green'}>
|
|
113
|
-
{taskSummary(block.task, children)}
|
|
115
|
+
{busy ? <span className="term-shimmer">{taskSummary(block.task, children)}</span> : taskSummary(block.task, children)}
|
|
114
116
|
</Row>
|
|
115
117
|
</Pressable>
|
|
116
118
|
{open ? (
|
|
@@ -119,7 +121,7 @@ export function TaskRow({
|
|
|
119
121
|
{block.children.map((leaf, index) => (
|
|
120
122
|
<Fragment key={leaf.key}>
|
|
121
123
|
{index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
|
|
122
|
-
{'run' in leaf ? <
|
|
124
|
+
{'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />}
|
|
123
125
|
</Fragment>
|
|
124
126
|
))}
|
|
125
127
|
</div>
|
|
@@ -170,28 +172,30 @@ export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affor
|
|
|
170
172
|
bleed="1ch"
|
|
171
173
|
className={cn('term-transcript', className)}
|
|
172
174
|
>
|
|
173
|
-
{
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
175
|
+
<PeerNamesProvider items={state.items}>
|
|
176
|
+
{blocks.map((block, index) => (
|
|
177
|
+
<Fragment key={block.key}>
|
|
178
|
+
{index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
|
|
179
|
+
{'run' in block ? (
|
|
180
|
+
<RunRow items={block.run} />
|
|
181
|
+
) : 'item' in block ? (
|
|
182
|
+
<TerminalItemView item={block.item} fileUrl={fileUrl} />
|
|
183
|
+
) : (
|
|
184
|
+
<TaskRow block={block} fileUrl={fileUrl} />
|
|
185
|
+
)}
|
|
186
|
+
</Fragment>
|
|
187
|
+
))}
|
|
188
|
+
{working(state) ? (
|
|
189
|
+
<>
|
|
190
|
+
{state.items.length > 0 ? <Blank /> : null}
|
|
191
|
+
<WorkingRow
|
|
192
|
+
label={state.status === 'starting' ? 'Starting…' : 'Working…'}
|
|
193
|
+
startedAt={runStartedAt}
|
|
194
|
+
tokens={state.contextUsage?.totalTokens}
|
|
195
|
+
/>
|
|
196
|
+
</>
|
|
197
|
+
) : null}
|
|
198
|
+
</PeerNamesProvider>
|
|
195
199
|
</TerminalSurface>
|
|
196
200
|
)
|
|
197
201
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
2
|
-
import { foldsTogether } from './tool-run.ts'
|
|
2
|
+
import { foldsTogether, isPeerSend } from './tool-run.ts'
|
|
3
3
|
|
|
4
4
|
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
5
5
|
|
|
@@ -42,7 +42,7 @@ export function taskChildItems(block: TaskBlock): TranscriptItem[] {
|
|
|
42
42
|
|
|
43
43
|
function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
|
|
44
44
|
const previous = out.at(-1)
|
|
45
|
-
if (isRunCall(item)) {
|
|
45
|
+
if (isRunCall(item) && !isPeerSend(item)) {
|
|
46
46
|
if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
|
|
47
47
|
previous.run.push(item)
|
|
48
48
|
previous.indices.push(index)
|
|
@@ -112,13 +112,21 @@ export function terminalBlocks(items: readonly TranscriptItem[], offset = 0, fol
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
|
|
115
|
-
if (previous
|
|
116
|
-
return
|
|
115
|
+
if (isPeerSend(previous) || isPeerSend(next)) {
|
|
116
|
+
return true
|
|
117
117
|
}
|
|
118
|
-
return
|
|
118
|
+
return !(previous.kind === 'tool_call' && next.kind === 'tool_call')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function blockKind(block: TerminalBlock): string {
|
|
122
|
+
if (!('item' in block)) {
|
|
123
|
+
return 'tool_call'
|
|
124
|
+
}
|
|
125
|
+
// A peer send is a tool call on the wire and a message on the page, so it takes the blank line
|
|
126
|
+
// every other message gets rather than sitting flush against the run above it.
|
|
127
|
+
return isPeerSend(block.item) ? 'peer_send' : block.item.kind
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
|
|
122
|
-
|
|
123
|
-
return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
|
|
131
|
+
return !(blockKind(previous) === 'tool_call' && blockKind(next) === 'tool_call')
|
|
124
132
|
}
|
|
@@ -5,7 +5,7 @@ import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.
|
|
|
5
5
|
import { IMAGE_BOX_LINES } from './image-box.ts'
|
|
6
6
|
import { collapsedResult } from './result-preview.ts'
|
|
7
7
|
import { todoLine, todoPreview } from './todos.ts'
|
|
8
|
-
import { runSummary, taskSummary } from './tool-run.ts'
|
|
8
|
+
import { isPeerSend, peerName, planRun, runSummary, taskSummary } from './tool-run.ts'
|
|
9
9
|
|
|
10
10
|
export type CellMetrics = {
|
|
11
11
|
width: number
|
|
@@ -595,8 +595,9 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
|
|
|
595
595
|
acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
|
|
596
596
|
}
|
|
597
597
|
if (item.text) {
|
|
598
|
-
|
|
599
|
-
|
|
598
|
+
const who = item.origin ? `${peerName(item.origin)}: ` : ''
|
|
599
|
+
for (const [index, line] of item.text.split('\n').entries()) {
|
|
600
|
+
acc = add(acc, rowH((index === 0 ? who : '') + (line || ' '), m, { extraPx }))
|
|
600
601
|
}
|
|
601
602
|
}
|
|
602
603
|
return acc
|
|
@@ -608,6 +609,10 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
|
|
|
608
609
|
return rowH(item.text, m, { extraPx })
|
|
609
610
|
}
|
|
610
611
|
case 'tool_call': {
|
|
612
|
+
// A closed peer send is exactly one ellipsised row, whatever the message's length.
|
|
613
|
+
if (isPeerSend(item)) {
|
|
614
|
+
return { px: m.line + extraPx, exact: true }
|
|
615
|
+
}
|
|
611
616
|
return toolRowHeight(item, m, extraPx)
|
|
612
617
|
}
|
|
613
618
|
case 'turn_result': {
|
|
@@ -640,8 +645,12 @@ export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeigh
|
|
|
640
645
|
return rowH(taskSummary(block.task, taskChildItems(block)), m)
|
|
641
646
|
}
|
|
642
647
|
if ('run' in block) {
|
|
643
|
-
const
|
|
644
|
-
|
|
648
|
+
const plan = planRun(block.run)
|
|
649
|
+
if (plan.kind === 'call') {
|
|
650
|
+
return itemHeight(plan.item, m)
|
|
651
|
+
}
|
|
652
|
+
const busy = plan.items.some((item) => item.status === 'running' || item.status === 'pending')
|
|
653
|
+
return rowH(runSummary(plan.items, busy), m)
|
|
645
654
|
}
|
|
646
655
|
return itemHeight(block.item, m)
|
|
647
656
|
}
|