@workerdeck/ui 0.21.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-BjNAnWMF.d.mts → SessionPanel-13l25ubU.d.mts} +15 -5
- package/build/{SessionPanel-DNuPKg5c.mjs → SessionPanel-DgwjH4Ve.mjs} +624 -221
- package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
- package/build/index.d.mts +54 -21
- package/build/index.mjs +7 -6
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +3547 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +10 -6
- 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/SessionList.tsx +3 -1
- package/src/components/agent/SessionPanel.tsx +20 -7
- package/src/components/agent/SessionSteps.tsx +10 -4
- 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/styles/scoped.entry.css +16 -0
- package/src/styles/terminal.css +1 -1
- package/src/styles/theme.css +160 -0
- package/build/SessionPanel-DNuPKg5c.mjs.map +0 -1
package/build/workspace.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as SessionPanelProps } from "./SessionPanel-
|
|
1
|
+
import { r as SessionPanelProps } from "./SessionPanel-13l25ubU.mjs";
|
|
2
2
|
import * as _$react from "react";
|
|
3
3
|
import { CSSProperties } from "react";
|
|
4
4
|
import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
|
package/build/workspace.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Ht as Button, Wt as cn, _t as Tip, ft as Splitter, mt as Spinner, t as SessionPanel, yt as TooltipProvider, zt as Input } from "./SessionPanel-DgwjH4Ve.mjs";
|
|
2
2
|
import { d as formatBytes } from "./status-BE-zg88x.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": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"types": "./build/format.d.mts",
|
|
27
27
|
"default": "./build/format.mjs"
|
|
28
28
|
},
|
|
29
|
-
"./theme.css": "./src/styles/theme.css"
|
|
29
|
+
"./theme.css": "./src/styles/theme.css",
|
|
30
|
+
"./scoped.css": "./build/scoped.css"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@base-ui/react": "^1.6.0",
|
|
@@ -38,9 +39,9 @@
|
|
|
38
39
|
"streamdown": "^2.5.0",
|
|
39
40
|
"tailwind-merge": "^3.6.0",
|
|
40
41
|
"use-stick-to-bottom": "^1.1.6",
|
|
41
|
-
"@workerdeck/
|
|
42
|
-
"@workerdeck/
|
|
43
|
-
"@workerdeck/
|
|
42
|
+
"@workerdeck/react": "0.22.0",
|
|
43
|
+
"@workerdeck/protocol": "0.22.0",
|
|
44
|
+
"@workerdeck/client": "0.22.0"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"monaco-editor": "^0.56.0",
|
|
@@ -56,12 +57,15 @@
|
|
|
56
57
|
"@fontsource/inter": "^5.2.8",
|
|
57
58
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
|
58
59
|
"@storybook/react-vite": "^9.1.20",
|
|
60
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
59
61
|
"@tailwindcss/vite": "^4.3.1",
|
|
60
62
|
"@types/node": "^22.10.0",
|
|
61
63
|
"@types/react": "^19.2.0",
|
|
62
64
|
"@types/react-dom": "^19.2.0",
|
|
63
65
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
|
64
66
|
"monaco-editor": "^0.56.0",
|
|
67
|
+
"postcss": "^8.5.26",
|
|
68
|
+
"postcss-selector-parser": "^7.1.5",
|
|
65
69
|
"react": "^19.2.0",
|
|
66
70
|
"react-dom": "^19.2.0",
|
|
67
71
|
"rimraf": "^6.1.3",
|
|
@@ -94,7 +98,7 @@
|
|
|
94
98
|
},
|
|
95
99
|
"scripts": {
|
|
96
100
|
"clean": "rimraf build",
|
|
97
|
-
"build": "tsdown",
|
|
101
|
+
"build": "tsdown && node scripts/build-scoped-css.mjs",
|
|
98
102
|
"typecheck": "tsgo -p tsconfig.json && tsgo -p tsconfig.test.json",
|
|
99
103
|
"test": "vitest run",
|
|
100
104
|
"dev": "vite",
|
|
@@ -444,7 +444,7 @@ export function Composer({
|
|
|
444
444
|
const errorRow = attachments?.error ? (
|
|
445
445
|
<div
|
|
446
446
|
className={cn(
|
|
447
|
-
'mx-auto mt-1 flex w-full max-w-[var(--wd-
|
|
447
|
+
'mx-auto mt-1 flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-danger',
|
|
448
448
|
)}>
|
|
449
449
|
<TriangleAlert className='size-3 shrink-0' />
|
|
450
450
|
<span className='min-w-0 flex-1'>{attachments.error}</span>
|
|
@@ -530,11 +530,11 @@ export function Composer({
|
|
|
530
530
|
}
|
|
531
531
|
|
|
532
532
|
return (
|
|
533
|
-
<div data-slot='composer' className={cn('px-
|
|
533
|
+
<div data-slot='composer' className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
|
|
534
534
|
<div
|
|
535
535
|
{...dropHandlers}
|
|
536
536
|
className={cn(
|
|
537
|
-
'mx-auto w-full max-w-[var(--wd-
|
|
537
|
+
'mx-auto w-full max-w-[var(--wd-transcript-max-width)] overflow-hidden border border-border bg-bg',
|
|
538
538
|
'transition-colors rounded-lg shadow-(--shadow-xs)',
|
|
539
539
|
'focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
|
|
540
540
|
dragging && 'border-ring ring-2 ring-ring/30',
|
|
@@ -545,11 +545,11 @@ export function Composer({
|
|
|
545
545
|
<AttachmentStrip attachments={attachments} />
|
|
546
546
|
) : null}
|
|
547
547
|
{inline ? (
|
|
548
|
-
// One row until the message needs more: the field grows
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
548
|
+
// One row until the message needs more: the field grows and the
|
|
549
|
+
// buttons stay bottom-aligned as it does (`items-end`). On a single
|
|
550
|
+
// line, the 28px buttons and the 20px line-height text need matching
|
|
551
|
+
// vertical padding so their visual centres land on the same line:
|
|
552
|
+
// 4px above + 4px below the 20px text = 28px, the button height.
|
|
553
553
|
<div className='flex items-end gap-1 p-1'>
|
|
554
554
|
{attach}
|
|
555
555
|
<PromptArea
|
|
@@ -564,7 +564,7 @@ export function Composer({
|
|
|
564
564
|
minHeight={20}
|
|
565
565
|
maxHeight={192}
|
|
566
566
|
aria-label='Message the agent'
|
|
567
|
-
className='min-w-0 flex-1 py-
|
|
567
|
+
className='min-w-0 flex-1 py-1 text-body-sm text-text'
|
|
568
568
|
onImagePaste={(file) => attachments?.add([file])}
|
|
569
569
|
/>
|
|
570
570
|
{submitButton}
|
|
@@ -583,7 +583,7 @@ export function Composer({
|
|
|
583
583
|
minHeight={28}
|
|
584
584
|
maxHeight={192}
|
|
585
585
|
aria-label='Message the agent'
|
|
586
|
-
className='px-3 pt-2.5 pb-
|
|
586
|
+
className='px-3 pt-2.5 pb-0 text-body-sm text-text'
|
|
587
587
|
onImagePaste={(file) => attachments?.add([file])}
|
|
588
588
|
/>
|
|
589
589
|
<div className='flex items-center justify-between gap-2 px-2 pb-2'>
|
|
@@ -7,6 +7,7 @@ export interface ContextDialogProps {
|
|
|
7
7
|
usage?: ContextUsage
|
|
8
8
|
open: boolean
|
|
9
9
|
onOpenChange: (open: boolean) => void
|
|
10
|
+
className?: string
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
@@ -23,10 +24,10 @@ const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-wa
|
|
|
23
24
|
* are different questions asked at different moments, so they are different
|
|
24
25
|
* screens rather than one "details" list you scroll past two answers to reach.
|
|
25
26
|
*/
|
|
26
|
-
export function ContextDialog({ usage, open, onOpenChange }: ContextDialogProps) {
|
|
27
|
+
export function ContextDialog({ usage, open, onOpenChange, className }: ContextDialogProps) {
|
|
27
28
|
return (
|
|
28
29
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
29
|
-
<DialogContent>
|
|
30
|
+
<DialogContent className={className}>
|
|
30
31
|
<DialogHeader title='Context' description={usage?.model} />
|
|
31
32
|
<DialogBody>
|
|
32
33
|
{!usage ? (
|
|
@@ -40,7 +40,7 @@ export function ConversationContent({
|
|
|
40
40
|
return (
|
|
41
41
|
<StickToBottom.Content
|
|
42
42
|
data-slot='conversation-content'
|
|
43
|
-
className={cn('mx-auto flex w-full max-w-[var(--wd-
|
|
43
|
+
className={cn('mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-4 px-4 py-4', className)}
|
|
44
44
|
{...props}>
|
|
45
45
|
{children}
|
|
46
46
|
</StickToBottom.Content>
|
|
@@ -24,6 +24,7 @@ export interface McpDialogProps {
|
|
|
24
24
|
* that 501 are worse than buttons that aren't there.
|
|
25
25
|
*/
|
|
26
26
|
canManageServers?: boolean
|
|
27
|
+
className?: string
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** The engine's status vocabulary is open — anything unrecognised renders
|
|
@@ -54,6 +55,7 @@ export function McpDialog({
|
|
|
54
55
|
open,
|
|
55
56
|
onOpenChange,
|
|
56
57
|
canManageServers = true,
|
|
58
|
+
className,
|
|
57
59
|
}: McpDialogProps) {
|
|
58
60
|
const [servers, setServers] = useState<McpServerStatusInfo[] | undefined>()
|
|
59
61
|
const [error, setError] = useState<string | undefined>()
|
|
@@ -103,7 +105,7 @@ export function McpDialog({
|
|
|
103
105
|
|
|
104
106
|
return (
|
|
105
107
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
106
|
-
<DialogContent>
|
|
108
|
+
<DialogContent className={className}>
|
|
107
109
|
<DialogHeader
|
|
108
110
|
title={title}
|
|
109
111
|
description={
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
4
|
+
import { toolInputPreview } from '../../lib/format.ts'
|
|
5
|
+
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import {
|
|
7
|
+
approvalCluster,
|
|
8
|
+
buildMarks,
|
|
9
|
+
clusterMarks,
|
|
10
|
+
doneLine,
|
|
11
|
+
excerpt,
|
|
12
|
+
KIND_NAME,
|
|
13
|
+
nearestMember,
|
|
14
|
+
type Cluster,
|
|
15
|
+
type Mark,
|
|
16
|
+
} from './scrubber-marks.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The cards variant's overview rail — the terminal scrubber's proportional
|
|
20
|
+
* sibling.
|
|
21
|
+
*
|
|
22
|
+
* Positions are **index space**, not pixel space: mark y is
|
|
23
|
+
* `itemIndex / items.length` of the rail, because proportional text and
|
|
24
|
+
* variable row heights leave the cards transcript with no honest pixel claim
|
|
25
|
+
* (the terminal rail's positions ride the height calculator, which has none
|
|
26
|
+
* here). Less precise, but it still answers the reader's questions — where did
|
|
27
|
+
* I type, where did it fail, where is the approval waiting.
|
|
28
|
+
*
|
|
29
|
+
* And because the positions are approximate, the rail is an **annotation, not
|
|
30
|
+
* a scrollbar**: no drag-to-scrub, no viewport band (meaningless without pixel
|
|
31
|
+
* positions), and the native scrollbar stays. The container never takes the
|
|
32
|
+
* pointer — only the marks do, when interactive — so the scrollbar keeps
|
|
33
|
+
* working straight through the paint. Hover peeks (from `items`, never the DOM
|
|
34
|
+
* — the row a mark describes is usually unmounted) and a click jumps to the
|
|
35
|
+
* mark's item through `onJumpToItem`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ScrubberProps {
|
|
39
|
+
items: readonly TranscriptItem[]
|
|
40
|
+
pendingApprovals: readonly PermissionRequest[]
|
|
41
|
+
/** The catch-up boundary's item index, when the recap is spliced in. */
|
|
42
|
+
recapItemIndex?: number
|
|
43
|
+
/** Bookmarked item indices. Paint only — no store, no set affordance. */
|
|
44
|
+
bookmarks?: readonly number[]
|
|
45
|
+
/** The sub-agent takeover's parent id, when this rail belongs to a frame —
|
|
46
|
+
* what "top level" means to the mark walk (see `buildMarks`). */
|
|
47
|
+
frameParentId?: string
|
|
48
|
+
/** Whether the rail answers the pointer (hover peek, click to jump). False
|
|
49
|
+
* renders passive paint. */
|
|
50
|
+
interactive?: boolean
|
|
51
|
+
/** Jump to an item index. The caller owns the row mapping — item indices are
|
|
52
|
+
* not row indices (`rowIndexForItem`). */
|
|
53
|
+
onJumpToItem?: (itemIndex: number) => void
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function peekContent(
|
|
58
|
+
cluster: Cluster,
|
|
59
|
+
/** The member the pointer resolved to — see {@link nearestMember}. */
|
|
60
|
+
first: Mark | undefined,
|
|
61
|
+
items: readonly TranscriptItem[],
|
|
62
|
+
pendingApprovals: readonly PermissionRequest[],
|
|
63
|
+
): ReactNode {
|
|
64
|
+
const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
|
|
65
|
+
let body: ReactNode = null
|
|
66
|
+
if (cluster.kind === 'approval') {
|
|
67
|
+
const request = pendingApprovals[0]
|
|
68
|
+
body = request ? (
|
|
69
|
+
<>
|
|
70
|
+
<div>{request.title ?? 'Permission required'}</div>
|
|
71
|
+
<div className='wd-scrub-ex' data-tone='muted'>
|
|
72
|
+
{`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
|
|
73
|
+
</div>
|
|
74
|
+
</>
|
|
75
|
+
) : null
|
|
76
|
+
} else if (first && first.kind !== 'recap') {
|
|
77
|
+
const item = items[first.itemIndex]
|
|
78
|
+
if (first.kind === 'turn' || first.kind === 'turnFailed') {
|
|
79
|
+
// The merged mark's peek carries both halves: the message the turn ended
|
|
80
|
+
// on, and the done-line (with its reasons, when it failed).
|
|
81
|
+
const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
|
|
82
|
+
body = (
|
|
83
|
+
<>
|
|
84
|
+
{item?.kind === 'assistant_text' ? <div className='wd-scrub-ex'>{item.text}</div> : null}
|
|
85
|
+
{turn?.kind === 'turn_result' ? (
|
|
86
|
+
<>
|
|
87
|
+
<div data-tone={turn.isError ? 'danger' : 'muted'}>{doneLine(turn)}</div>
|
|
88
|
+
{turn.errors?.map((message, index) => (
|
|
89
|
+
<div key={index} data-tone='danger'>
|
|
90
|
+
{message}
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</>
|
|
94
|
+
) : null}
|
|
95
|
+
</>
|
|
96
|
+
)
|
|
97
|
+
} else if (item) {
|
|
98
|
+
const failure =
|
|
99
|
+
first.kind === 'toolFailed' && item.kind === 'tool_call'
|
|
100
|
+
? item.result?.text.split('\n').find((line) => line.trim() !== '')
|
|
101
|
+
: undefined
|
|
102
|
+
body = (
|
|
103
|
+
<>
|
|
104
|
+
<div
|
|
105
|
+
className='wd-scrub-ex'
|
|
106
|
+
data-tone={
|
|
107
|
+
first.kind === 'error' || first.kind === 'toolFailed' ? 'danger' : undefined
|
|
108
|
+
}>
|
|
109
|
+
{first.kind === 'user' ? <span data-tone='muted'>{'❯ '}</span> : null}
|
|
110
|
+
{excerpt(item)}
|
|
111
|
+
</div>
|
|
112
|
+
{/* Which tool failed is rarely the question — the first non-blank
|
|
113
|
+
line of what it said back is the thing worth peeking at. */}
|
|
114
|
+
{failure ? (
|
|
115
|
+
<div className='wd-scrub-ex' data-tone='danger'>
|
|
116
|
+
{failure}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<div data-tone='muted'>
|
|
126
|
+
{KIND_NAME[first?.kind ?? cluster.kind]}
|
|
127
|
+
{more}
|
|
128
|
+
</div>
|
|
129
|
+
{body}
|
|
130
|
+
</>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function Scrubber({
|
|
135
|
+
items,
|
|
136
|
+
pendingApprovals,
|
|
137
|
+
recapItemIndex,
|
|
138
|
+
bookmarks,
|
|
139
|
+
frameParentId,
|
|
140
|
+
interactive = false,
|
|
141
|
+
onJumpToItem,
|
|
142
|
+
className,
|
|
143
|
+
}: ScrubberProps) {
|
|
144
|
+
const railRef = useRef<HTMLDivElement | null>(null)
|
|
145
|
+
const peekRef = useRef<HTMLDivElement | null>(null)
|
|
146
|
+
const [railH, setRailH] = useState(0)
|
|
147
|
+
const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(
|
|
148
|
+
null,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const element = railRef.current
|
|
153
|
+
if (!element) return
|
|
154
|
+
const observer = new ResizeObserver(() => setRailH(element.clientHeight))
|
|
155
|
+
observer.observe(element)
|
|
156
|
+
setRailH(element.clientHeight)
|
|
157
|
+
return () => observer.disconnect()
|
|
158
|
+
}, [])
|
|
159
|
+
|
|
160
|
+
// A peek is a snapshot of the cluster it was opened on; if the transcript
|
|
161
|
+
// changes underneath (a fixture/session swap, a burst of new items), drop it
|
|
162
|
+
// rather than describe items that no longer exist.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
setPeek(null)
|
|
165
|
+
}, [items])
|
|
166
|
+
|
|
167
|
+
// The peek can be taller than the space beside its mark — clamp it into the
|
|
168
|
+
// rail after it has a measured height.
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
const element = peekRef.current
|
|
171
|
+
if (!element || !peek) return
|
|
172
|
+
const height = element.offsetHeight
|
|
173
|
+
const railHeight = railRef.current?.clientHeight ?? 0
|
|
174
|
+
element.style.top = `${Math.max(4, Math.min(railHeight - height - 4, peek.y - height / 2))}px`
|
|
175
|
+
}, [peek])
|
|
176
|
+
|
|
177
|
+
const clusters = useMemo(() => {
|
|
178
|
+
if (railH <= 0) return []
|
|
179
|
+
const built = clusterMarks(
|
|
180
|
+
buildMarks(items, { frameParentId, bookmarks, recapItemIndex }),
|
|
181
|
+
railH,
|
|
182
|
+
items.length,
|
|
183
|
+
)
|
|
184
|
+
if (pendingApprovals.length > 0) built.push(approvalCluster(railH))
|
|
185
|
+
return built
|
|
186
|
+
}, [items, frameParentId, bookmarks, recapItemIndex, pendingApprovals, railH])
|
|
187
|
+
|
|
188
|
+
/** A pointer's y in rail space. */
|
|
189
|
+
const railY = (clientY: number): number =>
|
|
190
|
+
clientY - (railRef.current?.getBoundingClientRect().top ?? 0)
|
|
191
|
+
|
|
192
|
+
const activate = (cluster: Cluster, clientY: number) => {
|
|
193
|
+
if (cluster.kind === 'approval' && cluster.marks.length === 0) {
|
|
194
|
+
// The approval prompt renders below the transcript — the closest an
|
|
195
|
+
// item jump can take the reader is the tail.
|
|
196
|
+
if (items.length > 0) onJumpToItem?.(items.length - 1)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
const mark = nearestMember(cluster, railY(clientY))
|
|
200
|
+
if (mark) onJumpToItem?.(mark.itemIndex)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const showPeek = (cluster: Cluster, clientY: number) => {
|
|
204
|
+
const y = railY(clientY)
|
|
205
|
+
const mark = nearestMember(cluster, y)
|
|
206
|
+
setPeek((previous) =>
|
|
207
|
+
previous && previous.cluster === cluster && previous.mark === mark
|
|
208
|
+
? previous
|
|
209
|
+
: { cluster, mark, y: Math.min(Math.max(y, cluster.y), cluster.y + cluster.h) },
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Paint either way: the rail duplicates information the transcript itself
|
|
214
|
+
// carries, and interactive it is a pointer affordance rather than the
|
|
215
|
+
// scroll surface — the native scrollbar stays the accessible one.
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
ref={railRef}
|
|
219
|
+
className={cn('wd-scrubber', className)}
|
|
220
|
+
data-interactive={interactive || undefined}
|
|
221
|
+
aria-hidden>
|
|
222
|
+
{clusters.map((cluster, index) => (
|
|
223
|
+
<div
|
|
224
|
+
key={index}
|
|
225
|
+
className='wd-scrub-mark'
|
|
226
|
+
data-lane={cluster.lane}
|
|
227
|
+
data-kind={cluster.kind}
|
|
228
|
+
style={{ top: cluster.y, height: cluster.h }}
|
|
229
|
+
{...(interactive
|
|
230
|
+
? {
|
|
231
|
+
onClick: (event) => activate(cluster, event.clientY),
|
|
232
|
+
onPointerEnter: (event) => showPeek(cluster, event.clientY),
|
|
233
|
+
// A chain-merged bar can span the rail; sliding along it
|
|
234
|
+
// retargets the peek to the member under the pointer.
|
|
235
|
+
onPointerMove: (event) => showPeek(cluster, event.clientY),
|
|
236
|
+
onPointerLeave: () => setPeek(null),
|
|
237
|
+
}
|
|
238
|
+
: null)}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
{peek ? (
|
|
242
|
+
<div ref={peekRef} className='wd-scrub-peek' style={{ top: peek.y }}>
|
|
243
|
+
{peekContent(peek.cluster, peek.mark, items, pendingApprovals)}
|
|
244
|
+
</div>
|
|
245
|
+
) : null}
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
@@ -11,9 +11,10 @@ export interface SessionListItemProps {
|
|
|
11
11
|
active?: boolean
|
|
12
12
|
onSelect?: (id: string) => void
|
|
13
13
|
onDelete?: (id: string) => void
|
|
14
|
+
className?: string
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function SessionListItem({ session, active, onSelect, onDelete }: SessionListItemProps) {
|
|
17
|
+
export function SessionListItem({ session, active, onSelect, onDelete, className }: SessionListItemProps) {
|
|
17
18
|
const meta = STATUS_META[session.status]
|
|
18
19
|
return (
|
|
19
20
|
<div
|
|
@@ -22,6 +23,7 @@ export function SessionListItem({ session, active, onSelect, onDelete }: Session
|
|
|
22
23
|
className={cn(
|
|
23
24
|
'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
|
|
24
25
|
active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
|
|
26
|
+
className,
|
|
25
27
|
)}>
|
|
26
28
|
<button
|
|
27
29
|
type='button'
|
|
@@ -98,7 +98,7 @@ function PromptSurface({
|
|
|
98
98
|
}) {
|
|
99
99
|
if (!terminal) {
|
|
100
100
|
return (
|
|
101
|
-
<div className='mx-auto flex w-full max-w-[var(--wd-
|
|
101
|
+
<div className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2'>
|
|
102
102
|
{children}
|
|
103
103
|
</div>
|
|
104
104
|
)
|
|
@@ -297,10 +297,10 @@ export interface SessionPanelProps {
|
|
|
297
297
|
*/
|
|
298
298
|
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
300
|
+
* Hold the prompt of the turn you are reading at the top of the transcript.
|
|
301
|
+
* Works in both variants: the terminal clips to one line (as the CLI does),
|
|
302
|
+
* cards shows a frosted bar. The **real row** is pinned rather than a copy
|
|
303
|
+
* drawn above it, so it lines up with the rows beneath by construction.
|
|
304
304
|
*/
|
|
305
305
|
stickyPrompt?: boolean
|
|
306
306
|
/**
|
|
@@ -410,6 +410,12 @@ export interface SessionPanelProps {
|
|
|
410
410
|
* the client cannot see.
|
|
411
411
|
*/
|
|
412
412
|
cacheTranscript?: boolean
|
|
413
|
+
/**
|
|
414
|
+
* Replaces the default empty state when the transcript has no messages. Pass
|
|
415
|
+
* your product's own onboarding content instead of WorkerDeck's generic
|
|
416
|
+
* "`>_` Tell the agent what to do." placeholder.
|
|
417
|
+
*/
|
|
418
|
+
emptyState?: ReactNode
|
|
413
419
|
className?: string
|
|
414
420
|
}
|
|
415
421
|
|
|
@@ -483,6 +489,9 @@ export type SessionVitals = {
|
|
|
483
489
|
* as its "seen" watermark while the panel is actually on screen, and compares
|
|
484
490
|
* against later to know what is new. */
|
|
485
491
|
itemCount: number
|
|
492
|
+
/** Session-cumulative cost in USD. The internal status bar renders this via
|
|
493
|
+
* `formatCost`; an external host needs it to reproduce that reading. */
|
|
494
|
+
totalCostUsd: number
|
|
486
495
|
}
|
|
487
496
|
|
|
488
497
|
/**
|
|
@@ -521,6 +530,7 @@ export function SessionPanel({
|
|
|
521
530
|
readOnly = false,
|
|
522
531
|
toolHost,
|
|
523
532
|
cacheTranscript,
|
|
533
|
+
emptyState,
|
|
524
534
|
className,
|
|
525
535
|
}: SessionPanelProps) {
|
|
526
536
|
const external = panelSurface === 'external'
|
|
@@ -786,6 +796,7 @@ export function SessionPanel({
|
|
|
786
796
|
contextUsage: state.contextUsage,
|
|
787
797
|
rateLimits,
|
|
788
798
|
itemCount: state.items.length,
|
|
799
|
+
totalCostUsd: state.totalCostUsd,
|
|
789
800
|
})
|
|
790
801
|
}, [
|
|
791
802
|
state.status,
|
|
@@ -800,6 +811,7 @@ export function SessionPanel({
|
|
|
800
811
|
state.contextUsage,
|
|
801
812
|
rateLimits,
|
|
802
813
|
state.items.length,
|
|
814
|
+
state.totalCostUsd,
|
|
803
815
|
])
|
|
804
816
|
|
|
805
817
|
// The commands back in. One stable object reading through refs, so an
|
|
@@ -1089,6 +1101,7 @@ export function SessionPanel({
|
|
|
1089
1101
|
reveal={returnReveal ?? reveal}
|
|
1090
1102
|
frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
|
|
1091
1103
|
onOpenSubagent={setSubagentId}
|
|
1104
|
+
emptyState={emptyState}
|
|
1092
1105
|
jumpToRecapRef={jumpToRecap}
|
|
1093
1106
|
repinRef={repinTranscript}
|
|
1094
1107
|
/>
|
|
@@ -1101,7 +1114,7 @@ export function SessionPanel({
|
|
|
1101
1114
|
<div className='px-3 pb-1'>
|
|
1102
1115
|
<div
|
|
1103
1116
|
data-slot='catch-up'
|
|
1104
|
-
className='mx-auto flex w-full max-w-[var(--wd-
|
|
1117
|
+
className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3'>
|
|
1105
1118
|
<span
|
|
1106
1119
|
aria-hidden
|
|
1107
1120
|
className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
|
|
@@ -1370,7 +1383,7 @@ function Notice({
|
|
|
1370
1383
|
<div
|
|
1371
1384
|
role='alert'
|
|
1372
1385
|
className={cn(
|
|
1373
|
-
'mx-auto flex w-full max-w-[var(--wd-
|
|
1386
|
+
'mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
|
|
1374
1387
|
level === 'error'
|
|
1375
1388
|
? 'border-danger/40 bg-danger-bg text-danger'
|
|
1376
1389
|
: 'border-warning/40 bg-warning-bg text-warning',
|
|
@@ -41,10 +41,16 @@ export type Step = {
|
|
|
41
41
|
/** What one of these is called, for the disclosure's count. */
|
|
42
42
|
noun: string
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Both kinds press. The kind decides **where the press goes**, which is the
|
|
45
|
+
* whole of the distinction.
|
|
46
|
+
*
|
|
47
|
+
* An **agent** has an identity and work of its own, so it wears the sub-agent
|
|
48
|
+
* colour and opens that agent's own frame. A **task** is something the model
|
|
49
|
+
* described with no agent behind it (`isAgentRecord`), so there is no frame:
|
|
50
|
+
* framing its id selects no items and draws an empty screen. What it has
|
|
51
|
+
* instead is a *place* — the spawning call's row in the transcript — so it is
|
|
52
|
+
* muted, never selected, and travels there. See `StepRow` for why an inert
|
|
53
|
+
* row was the worse answer.
|
|
48
54
|
*
|
|
49
55
|
* Two values and not a boolean because the checklist source this shape was
|
|
50
56
|
* drawn for (see above) produces the second kind natively — a to-do is a task
|
|
@@ -12,6 +12,7 @@ export interface SkillsDialogProps {
|
|
|
12
12
|
/** Insert a skill's opening message into the composer, if the host offers
|
|
13
13
|
* that. Omit and the dialog is read-only. */
|
|
14
14
|
onUse?: (skill: SkillInfo) => void
|
|
15
|
+
className?: string
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/** Where the skill came from. The engine's set is open, so an unrecognised
|
|
@@ -36,7 +37,7 @@ const SCOPE_LABEL: Record<string, string> = {
|
|
|
36
37
|
* Fed from the session's `skills` event rather than a REST route, because that
|
|
37
38
|
* is the channel the engine refreshes on its own when a skill changes on disk.
|
|
38
39
|
*/
|
|
39
|
-
export function SkillsDialog({ skills, open, onOpenChange, onUse }: SkillsDialogProps) {
|
|
40
|
+
export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: SkillsDialogProps) {
|
|
40
41
|
const [selected, setSelected] = useState<string | undefined>()
|
|
41
42
|
const skill = skills?.find((s) => s.name === selected)
|
|
42
43
|
|
|
@@ -47,7 +48,7 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse }: SkillsDialog
|
|
|
47
48
|
if (!next) setSelected(undefined)
|
|
48
49
|
onOpenChange(next)
|
|
49
50
|
}}>
|
|
50
|
-
<DialogContent>
|
|
51
|
+
<DialogContent className={className}>
|
|
51
52
|
<DialogHeader
|
|
52
53
|
title={skill ? (skill.displayName ?? skill.name) : 'Skills'}
|
|
53
54
|
description={
|
|
@@ -211,7 +211,7 @@ export function StatusBar({
|
|
|
211
211
|
// One explicit height, shared with the docked composer above it (see
|
|
212
212
|
// `Composer.tsx`) — the two strips along the foot of the panel read as
|
|
213
213
|
// one piece of chrome, and a pixel of drift between them shows.
|
|
214
|
-
'flex h-[
|
|
214
|
+
'flex h-[var(--wd-status-bar-height)] items-baseline gap-2 border-border bg-surface p-1.5',
|
|
215
215
|
// The rule goes between the bar and the content, so which edge it sits
|
|
216
216
|
// on follows the placement — except under the terminal theme at the
|
|
217
217
|
// foot, where the composer directly above already closes itself with a
|