@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
|
@@ -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
|
+
}
|