@workerdeck/ui 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/build/{SessionPanel-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
- package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
- package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
- package/build/index.d.mts +309 -53
- package/build/index.mjs +534 -397
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +3547 -0
- package/build/workspace.d.mts +10 -1
- package/build/workspace.mjs +4 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +15 -7
- package/src/components/agent/Composer.tsx +10 -10
- package/src/components/agent/ContextDialog.tsx +3 -2
- package/src/components/agent/Conversation.tsx +1 -1
- package/src/components/agent/McpDialog.tsx +3 -1
- package/src/components/agent/Scrubber.tsx +248 -0
- package/src/components/agent/SessionBrowser.tsx +113 -293
- package/src/components/agent/SessionItem.tsx +538 -0
- package/src/components/agent/SessionList.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +112 -21
- package/src/components/agent/SessionStatusIcon.tsx +43 -0
- package/src/components/agent/SessionSteps.tsx +118 -61
- package/src/components/agent/SessionWorkspace.tsx +11 -0
- package/src/components/agent/SkillsDialog.tsx +3 -2
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/Transcript.tsx +147 -73
- package/src/components/agent/UsageDialog.tsx +3 -1
- package/src/components/agent/scrubber-marks.ts +277 -0
- package/src/components/terminal/PermissionPrompt.tsx +3 -0
- package/src/components/terminal/QuestionPrompt.tsx +3 -0
- package/src/components/terminal/StatusLine.tsx +3 -1
- package/src/components/ui/AlertDialog.tsx +23 -20
- package/src/components/ui/Dialog.tsx +26 -23
- package/src/components/ui/Menu.tsx +20 -17
- package/src/components/ui/PortalScope.tsx +27 -0
- package/src/components/ui/Select.tsx +19 -16
- package/src/components/ui/Tooltip.tsx +13 -10
- package/src/index.ts +5 -1
- package/src/styles/scoped.entry.css +16 -0
- package/src/styles/terminal.css +1 -1
- package/src/styles/theme.css +223 -8
- package/build/SessionPanel-CHktI2CF.mjs.map +0 -1
|
@@ -31,6 +31,7 @@ import { taskBrief } from '../terminal/tool-run.ts'
|
|
|
31
31
|
import { taskBusy } from '../terminal/tool-run.ts'
|
|
32
32
|
import { briefPx, estimateBlockPx } from '../terminal/height.ts'
|
|
33
33
|
import { TerminalScrubber } from '../terminal/scrubber.tsx'
|
|
34
|
+
import { Scrubber } from './Scrubber.tsx'
|
|
34
35
|
import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
|
|
35
36
|
import { useHeightEpoch } from './use-height-epoch.ts'
|
|
36
37
|
import { useTranscriptJumps } from './use-transcript-jumps.ts'
|
|
@@ -320,59 +321,106 @@ function nestedClass(item: TranscriptItem, frameParentId?: string): string | und
|
|
|
320
321
|
// here because this file is where consumers have always found them.
|
|
321
322
|
export { rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
|
|
322
323
|
|
|
324
|
+
/** The cards head's one line: the prompt as a plain run of text. Deliberately
|
|
325
|
+
* not the Message component — a proportional card clipped by height is a
|
|
326
|
+
* sliced bubble, and un-styling one from CSS is a fight (see theme.css's
|
|
327
|
+
* sticky-prompt block for the other half of this decision). Prompt syntax
|
|
328
|
+
* stays literal — a 28px bar is a reminder of what was asked, not a rendering
|
|
329
|
+
* surface — and newlines collapse under the bar's `nowrap`. Attachment-only
|
|
330
|
+
* prompts fall back to the attachments' names so the bar is never blank. */
|
|
331
|
+
function promptHeadText(item: Extract<TranscriptItem, { kind: 'user' }>): string {
|
|
332
|
+
return item.text || (item.attachments ?? []).map((attachment) => attachment.name).join(', ')
|
|
333
|
+
}
|
|
334
|
+
|
|
323
335
|
/**
|
|
324
336
|
* A prompt row's sticky lane — the strip spanning its turn, leading with the
|
|
325
337
|
* one-line pinned **head** (see the pinned-prompt comment in
|
|
326
338
|
* {@link TranscriptRows}).
|
|
327
339
|
*
|
|
340
|
+
* The head's content is the variant's: the terminal passes the row again
|
|
341
|
+
* (clipping it to one line is exact under a monospace grid), cards passes the
|
|
342
|
+
* prompt as plain text for theme.css to draw as a compact bar. Only the
|
|
343
|
+
* terminal head carries the row's gap class — its stuck geometry parks that
|
|
344
|
+
* padding above the viewport edge so the visible line docks at zero
|
|
345
|
+
* (terminal.css); the cards bar never aligns with the row in flow, so the gap
|
|
346
|
+
* stays off it entirely and the 1st prompt and the Nth share one geometry.
|
|
347
|
+
*
|
|
328
348
|
* The head starts `visibility: hidden` and shows only while actually stuck —
|
|
329
349
|
* an overlay that is visible in flow would sit on the real row's first line
|
|
330
350
|
* and swallow its selection highlight, which reads as "the first line cannot
|
|
331
351
|
* be selected". CSS cannot ask "am I stuck?", so a 1px sentinel at the head's
|
|
332
|
-
* engage threshold (the line's own y)
|
|
333
|
-
*
|
|
334
|
-
*
|
|
352
|
+
* engage threshold (the line's own y) answers it: sentinel above the
|
|
353
|
+
* scrollport top → stuck, read by a passive scroll listener. It was an
|
|
354
|
+
* IntersectionObserver once, for the "no per-scroll work" purity — and that
|
|
355
|
+
* was a real bug: IO is edge-triggered, and an *instant* jump (the open-at-
|
|
356
|
+
* bottom pin, `jumpToRow`, a reveal) teleports the sentinel from below the
|
|
357
|
+
* viewport to above it between two observations — ratio 0 → 0, no threshold
|
|
358
|
+
* crossed, `isIntersecting` unchanged — so no entry is ever queued and the
|
|
359
|
+
* flag strands, in whichever direction the jump left it (observed: a session
|
|
360
|
+
* opened at the bottom, its prompt bar missing; the stale-true twin paints a
|
|
361
|
+
* bar over the real bubble). The flag needs level-triggered truth. The cost
|
|
362
|
+
* is two rect reads per scroll event per mounted lane — layout is clean
|
|
363
|
+
* during scrolling, and the pin itself is still the compositor's; only the
|
|
364
|
+
* bar's visibility rides the listener.
|
|
335
365
|
*/
|
|
336
366
|
function StickyPromptLane({
|
|
337
367
|
top,
|
|
338
368
|
height,
|
|
339
369
|
gapClass,
|
|
370
|
+
gapPx,
|
|
371
|
+
terminal,
|
|
340
372
|
scrollRoot,
|
|
341
373
|
index,
|
|
342
374
|
measureRef,
|
|
375
|
+
head,
|
|
343
376
|
content,
|
|
344
377
|
}: {
|
|
345
378
|
top: number
|
|
346
379
|
height: number
|
|
347
380
|
gapClass?: string | false
|
|
381
|
+
/** The gap's size in px (`ROW_GAP[...].px`) — the cards sentinel offset,
|
|
382
|
+
* where the terminal uses `--term-line` (exact against its own cell where a
|
|
383
|
+
* px constant would drift). */
|
|
384
|
+
gapPx: number
|
|
385
|
+
terminal: boolean
|
|
348
386
|
scrollRoot: HTMLElement | null
|
|
349
387
|
index: number
|
|
350
388
|
measureRef: (element: HTMLDivElement | null) => void
|
|
389
|
+
/** What the pinned head shows — see the component comment. */
|
|
390
|
+
head: ReactNode
|
|
351
391
|
content: ReactNode
|
|
352
392
|
}) {
|
|
353
393
|
const headRef = useRef<HTMLDivElement | null>(null)
|
|
354
394
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
395
|
+
// `top`/`height`/`gapClass` are deps because they move the sentinel without
|
|
396
|
+
// any scroll: a measurement refinement re-positions the lane under a still
|
|
397
|
+
// scroller, and only a fresh evaluation notices. Scroll covers the rest —
|
|
398
|
+
// including programmatic jumps, which fire a scroll event like any other
|
|
399
|
+
// write of `scrollTop`.
|
|
355
400
|
useEffect(() => {
|
|
356
|
-
const
|
|
401
|
+
const headElement = headRef.current
|
|
357
402
|
const sentinel = sentinelRef.current
|
|
358
|
-
if (!
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
observer.observe(sentinel)
|
|
372
|
-
return () => observer.disconnect()
|
|
373
|
-
}, [scrollRoot])
|
|
403
|
+
if (!headElement || !sentinel || !scrollRoot) return
|
|
404
|
+
const evaluate = () => {
|
|
405
|
+
// Strictly above the scrollport's top edge — at exact equality the real
|
|
406
|
+
// row's first line is itself flush with the top, and the head must not
|
|
407
|
+
// cover it.
|
|
408
|
+
const stuck =
|
|
409
|
+
sentinel.getBoundingClientRect().top < scrollRoot.getBoundingClientRect().top
|
|
410
|
+
headElement.toggleAttribute('data-stuck', stuck)
|
|
411
|
+
}
|
|
412
|
+
evaluate()
|
|
413
|
+
scrollRoot.addEventListener('scroll', evaluate, { passive: true })
|
|
414
|
+
return () => scrollRoot.removeEventListener('scroll', evaluate)
|
|
415
|
+
}, [scrollRoot, top, height, gapClass])
|
|
374
416
|
return (
|
|
375
|
-
|
|
417
|
+
// The attribute VALUE is the styling seam: terminal.css matches the bare
|
|
418
|
+
// attribute under its `[data-terminal]` scope, theme.css keys the cards
|
|
419
|
+
// bar on `[data-sticky-lane='cards']` — no `:not()` acrobatics either side.
|
|
420
|
+
<div
|
|
421
|
+
data-sticky-lane={terminal ? 'terminal' : 'cards'}
|
|
422
|
+
className='absolute inset-x-0'
|
|
423
|
+
style={{ top, height }}>
|
|
376
424
|
{/* The head rides in its own absolutely positioned sub-lane rather than
|
|
377
425
|
in flow with a cancelled footprint: sticky confinement clamps the
|
|
378
426
|
*margin* box, and a negative bottom margin shrinks that box to zero
|
|
@@ -381,15 +429,15 @@ function StickyPromptLane({
|
|
|
381
429
|
Out of flow, the border box is what gets clamped, and the push-off
|
|
382
430
|
lands exactly at the lane's bottom edge. */}
|
|
383
431
|
<div data-sticky-headlane='' aria-hidden>
|
|
384
|
-
<div ref={headRef} data-sticky-head='' className={gapClass || undefined}>
|
|
385
|
-
{
|
|
432
|
+
<div ref={headRef} data-sticky-head='' className={(terminal && gapClass) || undefined}>
|
|
433
|
+
{head}
|
|
386
434
|
</div>
|
|
387
435
|
</div>
|
|
388
436
|
<div
|
|
389
437
|
ref={sentinelRef}
|
|
390
438
|
aria-hidden
|
|
391
439
|
className='absolute left-0 w-px'
|
|
392
|
-
style={{ top: gapClass ? 'var(--term-line)' : 0, height: 1 }}
|
|
440
|
+
style={{ top: gapClass ? (terminal ? 'var(--term-line)' : gapPx) : 0, height: 1 }}
|
|
393
441
|
/>
|
|
394
442
|
<div ref={measureRef} data-index={index} className={gapClass || undefined}>
|
|
395
443
|
{content}
|
|
@@ -525,7 +573,7 @@ function TranscriptRows({
|
|
|
525
573
|
enabled: false,
|
|
526
574
|
promptRows: [],
|
|
527
575
|
})
|
|
528
|
-
pinRef.current = { enabled:
|
|
576
|
+
pinRef.current = { enabled: stickyPrompt, promptRows }
|
|
529
577
|
useEffect(() => {
|
|
530
578
|
setScrollElement(stick.scrollRef.current)
|
|
531
579
|
}, [stick.scrollRef])
|
|
@@ -882,13 +930,15 @@ function TranscriptRows({
|
|
|
882
930
|
</div>
|
|
883
931
|
)
|
|
884
932
|
// A prompt row's sticky lane — see the pinned-prompt comment above.
|
|
885
|
-
// The lane is sized to the turn; the sticky **head**
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// the
|
|
889
|
-
// gap class
|
|
890
|
-
// the
|
|
891
|
-
//
|
|
933
|
+
// The lane is sized to the turn; the sticky **head** comes first, out
|
|
934
|
+
// of flow, and the *measured* element is the real row after it, so
|
|
935
|
+
// the virtualizer's heights are untouched by either. Under terminal
|
|
936
|
+
// the head is the same content again clipped to one line, and carries
|
|
937
|
+
// the row's gap class so its visible line sits on the same y while in
|
|
938
|
+
// flow (the pin parks that padding above the viewport edge when
|
|
939
|
+
// stuck). Under cards the head is the prompt as plain text — theme.css
|
|
940
|
+
// draws it as a compact bar — and carries no gap class: it is never
|
|
941
|
+
// an in-flow overlay of the row, so it has no y to match.
|
|
892
942
|
// Positioned with `top`, NOT the translate every other row gets:
|
|
893
943
|
// `position: sticky` is resolved at layout time and a transform is
|
|
894
944
|
// paint-only, so under a translate the head would stick against the
|
|
@@ -897,7 +947,6 @@ function TranscriptRows({
|
|
|
897
947
|
// Same predicate as `promptRows` above — the lane and the forced range
|
|
898
948
|
// must agree on which rows are prompts.
|
|
899
949
|
if (
|
|
900
|
-
terminal &&
|
|
901
950
|
stickyPrompt &&
|
|
902
951
|
'item' in row &&
|
|
903
952
|
row.item.kind === 'user' &&
|
|
@@ -914,9 +963,12 @@ function TranscriptRows({
|
|
|
914
963
|
top={virtualRow.start}
|
|
915
964
|
height={Math.max(laneEnd - virtualRow.start, 0)}
|
|
916
965
|
gapClass={gapClass}
|
|
966
|
+
gapPx={gap.px}
|
|
967
|
+
terminal={terminal}
|
|
917
968
|
scrollRoot={scrollElement}
|
|
918
969
|
index={virtualRow.index}
|
|
919
970
|
measureRef={virtualizer.measureElement}
|
|
971
|
+
head={terminal ? content : promptHeadText(row.item)}
|
|
920
972
|
content={content}
|
|
921
973
|
/>
|
|
922
974
|
)
|
|
@@ -938,31 +990,38 @@ function TranscriptRows({
|
|
|
938
990
|
— the virtualizer's offsets, the epoch, the row list, the jump — is
|
|
939
991
|
this component's. The portal target is the Conversation root
|
|
940
992
|
(`relative`), the same containing block the scroll button uses. */}
|
|
941
|
-
{
|
|
993
|
+
{scrubber && scrollElement?.parentElement
|
|
942
994
|
? createPortal(
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
995
|
+
terminal ? (
|
|
996
|
+
<TerminalScrubber
|
|
997
|
+
items={items}
|
|
998
|
+
pendingApprovals={pendingApprovals}
|
|
999
|
+
recapRow={recapRow}
|
|
1000
|
+
bookmarks={scrubberMarks ?? []}
|
|
1001
|
+
frameParentId={frameParentId}
|
|
1002
|
+
rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
|
|
1003
|
+
positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
|
|
1004
|
+
offsetOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0}
|
|
1005
|
+
sizeOfRow={(rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0}
|
|
1006
|
+
totalSize={virtualizer.getTotalSize()}
|
|
1007
|
+
scrollOffset={virtualizer.scrollOffset ?? 0}
|
|
1008
|
+
viewportH={virtualizer.scrollRect?.height ?? 0}
|
|
1009
|
+
onJumpToRow={(rowIndex) => jumpToRow(rowIndex, 'start')}
|
|
1010
|
+
interactive={scrubInteractive}
|
|
1011
|
+
fontSize={fontSize}
|
|
1012
|
+
lineHeight={lineHeight}
|
|
1013
|
+
/>
|
|
1014
|
+
) : (
|
|
1015
|
+
<Scrubber
|
|
1016
|
+
items={items}
|
|
1017
|
+
pendingApprovals={pendingApprovals}
|
|
1018
|
+
recapItemIndex={recapIndex >= 0 ? boundary : undefined}
|
|
1019
|
+
bookmarks={scrubberMarks}
|
|
1020
|
+
frameParentId={frameParentId}
|
|
1021
|
+
interactive={scrubInteractive}
|
|
1022
|
+
onJumpToItem={(itemIndex) => jumpToRow(rowIndexForItem(rows, itemIndex), 'start')}
|
|
1023
|
+
/>
|
|
1024
|
+
),
|
|
966
1025
|
scrollElement.parentElement,
|
|
967
1026
|
)
|
|
968
1027
|
: null}
|
|
@@ -1004,17 +1063,21 @@ export interface TranscriptProps {
|
|
|
1004
1063
|
/** Terminal theme only: the pointer affordances a real terminal cannot offer.
|
|
1005
1064
|
* `false` for none. See {@link TerminalAffordances}. */
|
|
1006
1065
|
affordances?: TerminalAffordances | boolean
|
|
1007
|
-
/**
|
|
1008
|
-
*
|
|
1066
|
+
/** Hold the prompt of the turn being read at the top of the scroller. Works
|
|
1067
|
+
* in both variants: the terminal clips to one line, cards shows a frosted
|
|
1068
|
+
* bar. The *real* row is pinned, not a copy — see `TranscriptRows`. */
|
|
1009
1069
|
stickyPrompt?: boolean
|
|
1010
1070
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1071
|
+
* Mount the overview scrubber — a 12px rail of marks (your prompts, each
|
|
1072
|
+
* turn's response and result as one mark, errors, the pending approval, the
|
|
1073
|
+
* catch-up boundary) over the scroller's right edge. Two rails behind one
|
|
1074
|
+
* prop: under `terminal` it is the pixel-exact ruler that replaces the
|
|
1075
|
+
* native scrollbar (positions ride the height calculator; drag scrubs), and
|
|
1076
|
+
* under `cards` it is the **proportional annotation rail** — positioned by
|
|
1077
|
+
* `itemIndex / items.length`, because proportional text gives the
|
|
1078
|
+
* calculator no claim there — where the native scrollbar stays and the rail
|
|
1079
|
+
* only peeks and jumps. With `affordances={false}` either rail degrades to
|
|
1080
|
+
* passive paint — no drag, peek or click.
|
|
1018
1081
|
*/
|
|
1019
1082
|
scrubber?: boolean
|
|
1020
1083
|
/**
|
|
@@ -1102,6 +1165,12 @@ export interface TranscriptProps {
|
|
|
1102
1165
|
* sessions list instead.
|
|
1103
1166
|
*/
|
|
1104
1167
|
onOpenSubagent?: (toolUseId: string) => void
|
|
1168
|
+
/**
|
|
1169
|
+
* Replaces the default empty state when the transcript has no items. Pass a
|
|
1170
|
+
* `ReactNode` to show your product's own onboarding instead of WorkerDeck's
|
|
1171
|
+
* generic "`>_` Tell the agent what to do." placeholder.
|
|
1172
|
+
*/
|
|
1173
|
+
emptyState?: ReactNode
|
|
1105
1174
|
className?: string
|
|
1106
1175
|
}
|
|
1107
1176
|
|
|
@@ -1126,6 +1195,7 @@ export function Transcript({
|
|
|
1126
1195
|
reveal,
|
|
1127
1196
|
frame,
|
|
1128
1197
|
onOpenSubagent,
|
|
1198
|
+
emptyState,
|
|
1129
1199
|
className,
|
|
1130
1200
|
}: TranscriptProps) {
|
|
1131
1201
|
const terminal = variant === 'terminal'
|
|
@@ -1231,12 +1301,16 @@ export function Transcript({
|
|
|
1231
1301
|
</div>
|
|
1232
1302
|
) : null
|
|
1233
1303
|
) : items.length === 0 && state.status !== 'starting' ? (
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1304
|
+
emptyState !== undefined ? (
|
|
1305
|
+
emptyState
|
|
1306
|
+
) : (
|
|
1307
|
+
<SessionEmptyState
|
|
1308
|
+
cwd={state.cwd}
|
|
1309
|
+
hasCommands={!!state.commands?.length}
|
|
1310
|
+
hasSkills={!!state.skills?.some((s) => s.enabled)}
|
|
1311
|
+
canBrowseFiles={canBrowseFiles}
|
|
1312
|
+
/>
|
|
1313
|
+
)
|
|
1240
1314
|
) : null}
|
|
1241
1315
|
{frame && frameTask === undefined && !replaying ? null : (
|
|
1242
1316
|
<TranscriptRows
|
|
@@ -1316,7 +1390,7 @@ export function Transcript({
|
|
|
1316
1390
|
className='wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden'>
|
|
1317
1391
|
<div
|
|
1318
1392
|
className={cn(
|
|
1319
|
-
'mx-auto w-full max-w-[var(--wd-
|
|
1393
|
+
'mx-auto w-full max-w-[var(--wd-transcript-max-width)]',
|
|
1320
1394
|
!terminal && 'px-4 py-4',
|
|
1321
1395
|
)}>
|
|
1322
1396
|
{terminal ? (
|
|
@@ -26,6 +26,7 @@ export interface UsageDialogProps {
|
|
|
26
26
|
updatedAt?: number
|
|
27
27
|
open: boolean
|
|
28
28
|
onOpenChange: (open: boolean) => void
|
|
29
|
+
className?: string
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -40,11 +41,12 @@ export function UsageDialog({
|
|
|
40
41
|
updatedAt,
|
|
41
42
|
open,
|
|
42
43
|
onOpenChange,
|
|
44
|
+
className,
|
|
43
45
|
}: UsageDialogProps) {
|
|
44
46
|
const now = useMinuteClock(open)
|
|
45
47
|
return (
|
|
46
48
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
47
|
-
<DialogContent>
|
|
49
|
+
<DialogContent className={className}>
|
|
48
50
|
<DialogHeader
|
|
49
51
|
title='Usage'
|
|
50
52
|
description={engine === 'claude' ? 'Claude Code' : engine}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
2
|
+
import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
3
|
+
import { parentOf } from '../terminal/blocks.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The scrubber's mark model, shared between the two rails.
|
|
7
|
+
*
|
|
8
|
+
* The terminal scrubber (`terminal/scrubber.tsx`) positions marks in **pixel
|
|
9
|
+
* space** — the virtualizer's row offsets, honest because the height calculator
|
|
10
|
+
* feeds `estimateSize`. The cards variant has no such claim (proportional text,
|
|
11
|
+
* variable row heights), so its rail (`agent/Scrubber.tsx`) positions marks in
|
|
12
|
+
* **index space**: `itemIndex / items.length` of the rail. Less precise, but it
|
|
13
|
+
* still answers the reader's questions — where did I type, where did it fail,
|
|
14
|
+
* where is the approval waiting.
|
|
15
|
+
*
|
|
16
|
+
* What is shared here is the *classification*: which items earn a mark, which
|
|
17
|
+
* lane a mark lives in, and who wins the colour when marks merge. The walk in
|
|
18
|
+
* {@link buildMarks} mirrors `buildClusters`'s first half (the segment
|
|
19
|
+
* machinery, sub-agent detection, bookmark/recap injection) minus everything
|
|
20
|
+
* that needs a row model. One deliberate divergence: the terminal marks a
|
|
21
|
+
* failed tool call only when it is its row's *outcome* (a fold's last member),
|
|
22
|
+
* because that is what its transcript reddens; cards folds nothing
|
|
23
|
+
* (`terminalBlocks` with `fold=false`), every failed top-level call reddens its
|
|
24
|
+
* own card, so every one marks — the same "the rail marks what the transcript
|
|
25
|
+
* reddens" rule, read against a surface with no folds.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type Lane = 'l' | 'r' | 'f'
|
|
29
|
+
export type MarkKind =
|
|
30
|
+
| 'user'
|
|
31
|
+
| 'subagent'
|
|
32
|
+
| 'turn'
|
|
33
|
+
| 'turnFailed'
|
|
34
|
+
| 'toolFailed'
|
|
35
|
+
| 'error'
|
|
36
|
+
| 'approval'
|
|
37
|
+
| 'recap'
|
|
38
|
+
| 'bookmark'
|
|
39
|
+
|
|
40
|
+
export type Mark = {
|
|
41
|
+
kind: MarkKind
|
|
42
|
+
/** The jump anchor (for a turn mark: the paired response). */
|
|
43
|
+
itemIndex: number
|
|
44
|
+
/** The `turn_result` behind a right-lane mark — the peek shows its done-line. */
|
|
45
|
+
turnIndex?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Members keep their own y: a dense transcript chain-merges a lane into one
|
|
49
|
+
* tall bar, and the bar answers the pointer by its *nearest member* — a press
|
|
50
|
+
* or peek at the middle of the bar must not act on the mark that happened to
|
|
51
|
+
* found the cluster. */
|
|
52
|
+
export type Cluster = {
|
|
53
|
+
lane: Lane
|
|
54
|
+
kind: MarkKind
|
|
55
|
+
y: number
|
|
56
|
+
h: number
|
|
57
|
+
marks: { mark: Mark; y: number }[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The member closest to a rail-space y — what a press or peek on a merged
|
|
61
|
+
* cluster resolves to. */
|
|
62
|
+
export function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
63
|
+
let best: { mark: Mark; y: number } | undefined
|
|
64
|
+
for (const member of cluster.marks)
|
|
65
|
+
if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) best = member
|
|
66
|
+
return best?.mark
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The two lanes are channels, not classes: left is what went *in* (your
|
|
70
|
+
* prompts, the sub-agents you dispatched), right is what came *out* (each
|
|
71
|
+
* turn's answer, and everything that went wrong producing one). Full width is
|
|
72
|
+
* for what is not a channel at all: the waiting approval, a bookmark, the
|
|
73
|
+
* catch-up seam. See the terminal scrubber for the full argument. */
|
|
74
|
+
export const LANE: Record<MarkKind, Lane> = {
|
|
75
|
+
user: 'l',
|
|
76
|
+
subagent: 'l',
|
|
77
|
+
turn: 'r',
|
|
78
|
+
turnFailed: 'r',
|
|
79
|
+
toolFailed: 'r',
|
|
80
|
+
error: 'r',
|
|
81
|
+
approval: 'f',
|
|
82
|
+
recap: 'f',
|
|
83
|
+
bookmark: 'f',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Who wins the colour when marks merge. */
|
|
87
|
+
export const LOUDNESS: Record<MarkKind, number> = {
|
|
88
|
+
approval: 7,
|
|
89
|
+
error: 6,
|
|
90
|
+
turnFailed: 5,
|
|
91
|
+
toolFailed: 4,
|
|
92
|
+
user: 3,
|
|
93
|
+
turn: 2,
|
|
94
|
+
bookmark: 1,
|
|
95
|
+
subagent: 1,
|
|
96
|
+
recap: 0,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const KIND_NAME: Record<MarkKind, string> = {
|
|
100
|
+
user: 'you',
|
|
101
|
+
subagent: 'sub-agent',
|
|
102
|
+
turn: 'response · turn end',
|
|
103
|
+
turnFailed: 'turn failed',
|
|
104
|
+
toolFailed: 'tool failed',
|
|
105
|
+
error: 'error',
|
|
106
|
+
approval: 'pending approval',
|
|
107
|
+
recap: 'catch-up boundary',
|
|
108
|
+
bookmark: 'bookmark',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The floor: an index-proportional mark has no extent to draw, so this is
|
|
112
|
+
* usually the height too — 2px keeps a tick findable. */
|
|
113
|
+
export const MIN_MARK = 2
|
|
114
|
+
|
|
115
|
+
export const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
|
|
116
|
+
`${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
117
|
+
|
|
118
|
+
export function excerpt(item: TranscriptItem): string {
|
|
119
|
+
switch (item.kind) {
|
|
120
|
+
case 'user':
|
|
121
|
+
case 'assistant_text':
|
|
122
|
+
case 'thinking':
|
|
123
|
+
case 'notice':
|
|
124
|
+
return item.text
|
|
125
|
+
case 'tool_call':
|
|
126
|
+
return `${item.name}(${toolInputPreview(item.input)})`
|
|
127
|
+
case 'turn_result':
|
|
128
|
+
return doneLine(item)
|
|
129
|
+
case 'file_delivered':
|
|
130
|
+
return item.path
|
|
131
|
+
default:
|
|
132
|
+
return ''
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One right-lane mark per segment, emitted when the segment closes — by the
|
|
137
|
+
* next prompt, by its own turn end, or by running out of items (which is what
|
|
138
|
+
* a replayed history is made of, since `#backfillHistory` carries no turn
|
|
139
|
+
* rows). A `turn_result` *decorates* the answer rather than conjuring the
|
|
140
|
+
* mark, so a live answer with no turn end yet is still on the rail. */
|
|
141
|
+
type Segment = { response?: number; turn?: number; failed?: boolean }
|
|
142
|
+
|
|
143
|
+
export interface BuildMarksOptions {
|
|
144
|
+
/** The sub-agent takeover's parent id, when the rail belongs to a frame — it
|
|
145
|
+
* is what "top level" means here (`undefined` at the conversation's own
|
|
146
|
+
* level). Inside a frame every narration step is its own mark: the stream
|
|
147
|
+
* carries no prompts and no `turn_result` for the segment machinery to work
|
|
148
|
+
* with, and a fifty-step agent run is exactly where a rail earns its keep. */
|
|
149
|
+
frameParentId?: string
|
|
150
|
+
/** Bookmarked item indices. Paint only — the store is the client's. */
|
|
151
|
+
bookmarks?: readonly number[]
|
|
152
|
+
/** The catch-up boundary's item index, when the recap is spliced in. */
|
|
153
|
+
recapItemIndex?: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function buildMarks(
|
|
157
|
+
items: readonly TranscriptItem[],
|
|
158
|
+
{ frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
|
|
159
|
+
): Mark[] {
|
|
160
|
+
const marks: Mark[] = []
|
|
161
|
+
// Which top-level calls a sub-agent ran inside — by `parentToolUseId`, never
|
|
162
|
+
// by the spawning call's *name*: `Task` is the SDK's convention (a background
|
|
163
|
+
// agent arrives as `Agent`), and an id other items demonstrably nest under IS
|
|
164
|
+
// a sub-agent whatever spawned it.
|
|
165
|
+
const subagentParents = new Set<string>()
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const parent = parentOf(item)
|
|
168
|
+
if (parent !== undefined) subagentParents.add(parent)
|
|
169
|
+
}
|
|
170
|
+
let segment: Segment = {}
|
|
171
|
+
const closeSegment = () => {
|
|
172
|
+
const anchor = segment.response ?? segment.turn
|
|
173
|
+
if (anchor !== undefined) {
|
|
174
|
+
marks.push({
|
|
175
|
+
kind: segment.failed ? 'turnFailed' : 'turn',
|
|
176
|
+
itemIndex: anchor,
|
|
177
|
+
turnIndex: segment.turn,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
segment = {}
|
|
181
|
+
}
|
|
182
|
+
items.forEach((item, index) => {
|
|
183
|
+
// The dispatch itself. Deliberately not part of the chain below: a `Task`
|
|
184
|
+
// whose own result errored earns a red tick in the response lane *and*
|
|
185
|
+
// this mark in the input lane — one says a sub-agent ran here, the other
|
|
186
|
+
// says it came back broken.
|
|
187
|
+
if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
|
|
188
|
+
marks.push({ kind: 'subagent', itemIndex: index })
|
|
189
|
+
}
|
|
190
|
+
// Top-level prompts only, like the answer check below: a sub-agent's brief
|
|
191
|
+
// is a `user` item too, and it would both paint a "you" mark for something
|
|
192
|
+
// nobody typed and close the segment mid-turn.
|
|
193
|
+
if (item.kind === 'user' && parentOf(item) === frameParentId) {
|
|
194
|
+
closeSegment()
|
|
195
|
+
marks.push({ kind: 'user', itemIndex: index })
|
|
196
|
+
} else if (item.kind === 'turn_result') {
|
|
197
|
+
segment.turn = index
|
|
198
|
+
segment.failed = item.isError
|
|
199
|
+
closeSegment()
|
|
200
|
+
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
201
|
+
marks.push({ kind: 'error', itemIndex: index })
|
|
202
|
+
} else if (
|
|
203
|
+
// Both spellings are needed: an out-of-loop execution failure sets
|
|
204
|
+
// `status` with no `is_error` block to read, and an engine can flag
|
|
205
|
+
// `is_error` on a call this reducer has not settled yet. Top level only
|
|
206
|
+
// — a sub-agent's failed child is represented by the sub-agent mark and
|
|
207
|
+
// the Task's own red tick, exactly as the terminal rail does.
|
|
208
|
+
item.kind === 'tool_call' &&
|
|
209
|
+
parentOf(item) === frameParentId &&
|
|
210
|
+
(item.status === 'failed' || item.result?.isError === true)
|
|
211
|
+
) {
|
|
212
|
+
marks.push({ kind: 'toolFailed', itemIndex: index })
|
|
213
|
+
} else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
|
|
214
|
+
if (frameParentId !== undefined) {
|
|
215
|
+
marks.push({ kind: 'turn', itemIndex: index })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
segment.response = index
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
// A history that ends mid-segment still has an answer in it.
|
|
222
|
+
closeSegment()
|
|
223
|
+
for (const index of bookmarks)
|
|
224
|
+
if (index >= 0 && index < items.length) marks.push({ kind: 'bookmark', itemIndex: index })
|
|
225
|
+
if (recapItemIndex !== undefined) marks.push({ kind: 'recap', itemIndex: recapItemIndex })
|
|
226
|
+
return marks
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Place marks proportionally — y = `itemIndex / itemCount` of the rail, every
|
|
231
|
+
* mark one item's share tall (floored at {@link MIN_MARK}) — then merge
|
|
232
|
+
* adjacent ones per lane, the loudest colour winning. The same merge rule as
|
|
233
|
+
* the terminal rail; only the position source differs.
|
|
234
|
+
*/
|
|
235
|
+
export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
|
|
236
|
+
const count = Math.max(1, itemCount)
|
|
237
|
+
const h = Math.max(MIN_MARK, Math.round(railH / count))
|
|
238
|
+
const lanes = new Map<Lane, { mark: Mark; y: number }[]>()
|
|
239
|
+
for (const mark of marks) {
|
|
240
|
+
const y = Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH))
|
|
241
|
+
const lane = LANE[mark.kind]
|
|
242
|
+
const list = lanes.get(lane) ?? []
|
|
243
|
+
list.push({ mark, y })
|
|
244
|
+
lanes.set(lane, list)
|
|
245
|
+
}
|
|
246
|
+
const clusters: Cluster[] = []
|
|
247
|
+
for (const [lane, list] of lanes) {
|
|
248
|
+
list.sort((a, b) => a.y - b.y)
|
|
249
|
+
let current: Cluster | null = null
|
|
250
|
+
for (const { mark, y } of list) {
|
|
251
|
+
// Merge when the gap is under a pixel; the merged mark grows and takes
|
|
252
|
+
// the loudest member's colour.
|
|
253
|
+
if (current && y <= current.y + current.h + 1) {
|
|
254
|
+
current.h = Math.max(current.h, y + h - current.y)
|
|
255
|
+
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) current.kind = mark.kind
|
|
256
|
+
current.marks.push({ mark, y })
|
|
257
|
+
} else {
|
|
258
|
+
current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
|
|
259
|
+
clusters.push(current)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return clusters
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The approval is not an item — the prompt renders below the transcript — so
|
|
267
|
+
* its mark pins at the rail's foot, where the prompt is. Built by hand (no
|
|
268
|
+
* item to derive a position from), hence `marks: []`. */
|
|
269
|
+
export function approvalCluster(railH: number): Cluster {
|
|
270
|
+
return {
|
|
271
|
+
lane: LANE.approval,
|
|
272
|
+
kind: 'approval',
|
|
273
|
+
y: Math.max(0, railH - MIN_MARK),
|
|
274
|
+
h: MIN_MARK,
|
|
275
|
+
marks: [],
|
|
276
|
+
}
|
|
277
|
+
}
|