@workerdeck/ui 3.0.1 → 3.1.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.
Files changed (55) hide show
  1. package/build/{SessionPanel-C1bU4yTM.d.mts → SessionPanel-BNNc8grK.d.mts} +9 -7
  2. package/build/index.d.mts +153 -45
  3. package/build/index.mjs +501 -226
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +15 -6
  6. package/build/{use-path-links-BEWxt0ZM.mjs → use-path-links-C6Fh5zdx.mjs} +1926 -2086
  7. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  12. package/src/components/agent/Composer.tsx +25 -47
  13. package/src/components/agent/ContextDialog.tsx +75 -67
  14. package/src/components/agent/FrameStrip.tsx +74 -0
  15. package/src/components/agent/HostFilesDialog.tsx +3 -2
  16. package/src/components/agent/McpDialog.tsx +112 -78
  17. package/src/components/agent/PermissionPrompt.tsx +13 -22
  18. package/src/components/agent/QuestionPrompt.tsx +6 -41
  19. package/src/components/agent/Scrubber.tsx +6 -75
  20. package/src/components/agent/SessionBrowser.tsx +125 -214
  21. package/src/components/agent/SessionFilters.tsx +266 -0
  22. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  23. package/src/components/agent/SessionItem.tsx +27 -7
  24. package/src/components/agent/SessionPanel.tsx +244 -488
  25. package/src/components/agent/SessionSteps.tsx +54 -11
  26. package/src/components/agent/ShellStrip.tsx +27 -43
  27. package/src/components/agent/SubagentStrip.tsx +13 -30
  28. package/src/components/agent/Transcript.tsx +13 -0
  29. package/src/components/agent/TranscriptItemView.tsx +6 -36
  30. package/src/components/agent/composer-commands.ts +80 -1
  31. package/src/components/agent/markdown-image.tsx +49 -1
  32. package/src/components/agent/scrubber-marks.ts +33 -11
  33. package/src/components/agent/scrubber-peek.tsx +122 -0
  34. package/src/components/agent/session-panel-providers.tsx +41 -0
  35. package/src/components/agent/shell-actions.tsx +36 -1
  36. package/src/components/agent/use-catch-up.ts +20 -0
  37. package/src/components/agent/use-frame.ts +88 -0
  38. package/src/components/agent/use-host-image.ts +81 -0
  39. package/src/components/agent/use-shell-frame.ts +12 -70
  40. package/src/components/agent/use-subagent-frame.ts +19 -75
  41. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  42. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  43. package/src/components/terminal/PermissionPrompt.tsx +19 -32
  44. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  45. package/src/components/terminal/TerminalTranscript.tsx +3 -86
  46. package/src/components/terminal/items.tsx +5 -43
  47. package/src/components/terminal/scrubber.tsx +25 -272
  48. package/src/components/ui/Popover.tsx +29 -0
  49. package/src/components/ui/Select.tsx +25 -0
  50. package/src/index.ts +46 -8
  51. package/src/lib/file-link.ts +3 -16
  52. package/src/lib/permission-prompt.ts +57 -0
  53. package/src/lib/question-answers.ts +65 -0
  54. package/build/use-path-links-BEWxt0ZM.mjs.map +0 -1
  55. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -1,110 +1,15 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useStickToBottomContext } from 'use-stick-to-bottom'
3
3
  import type { PermissionRequest } from '@workerdeck/protocol'
4
4
  import type { TranscriptItem } from '@workerdeck/react'
5
- import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
6
- import { parentOf } from './blocks.ts'
5
+ import { approvalCluster, buildMarks, clusterMarks, MIN_MARK, nearestMember, type Cluster, type Mark } from '../agent/scrubber-marks.ts'
6
+ import { peekContent, TERMINAL_PEEK } from '../agent/scrubber-peek.tsx'
7
7
  import { TerminalSurface } from './surface.tsx'
8
8
 
9
- type Lane = 'l' | 'r' | 'f'
10
- type MarkKind = 'user' | 'subagent' | 'shell' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
11
-
12
- type Mark = {
13
- kind: MarkKind
14
- itemIndex: number
15
- rowIndex: number
16
- turnIndex?: number
17
- }
18
-
19
- type Cluster = { lane: Lane; kind: MarkKind; y: number; h: number; marks: { mark: Mark; y: number }[] }
20
-
21
- function nearestMember(cluster: Cluster, y: number): Mark | undefined {
22
- let best: { mark: Mark; y: number } | undefined
23
- for (const member of cluster.marks) {
24
- if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) {
25
- best = member
26
- }
27
- }
28
- return best?.mark
29
- }
30
-
31
- const LANE: Record<MarkKind, Lane> = {
32
- user: 'l',
33
- subagent: 'l',
34
- shell: 'l',
35
- turn: 'r',
36
- turnFailed: 'r',
37
- toolFailed: 'r',
38
- error: 'r',
39
- approval: 'f',
40
- recap: 'f',
41
- bookmark: 'f',
42
- }
43
-
44
- const LOUDNESS: Record<MarkKind, number> = {
45
- approval: 7,
46
- error: 6,
47
- turnFailed: 5,
48
- toolFailed: 4,
49
- user: 3,
50
- turn: 2,
51
- bookmark: 1,
52
- subagent: 1,
53
- shell: 1,
54
- recap: 0,
55
- }
56
-
57
- const KIND_NAME: Record<MarkKind, string> = {
58
- user: 'you',
59
- subagent: 'sub-agent',
60
- shell: 'shell',
61
- turn: 'response · turn end',
62
- turnFailed: 'turn failed',
63
- toolFailed: 'tool failed',
64
- error: 'error',
65
- approval: 'pending approval',
66
- recap: 'catch-up boundary',
67
- bookmark: 'bookmark',
68
- }
69
-
70
- const MIN_MARK = 2
71
-
72
9
  export function railScale(railH: number, totalSize: number, viewportH: number): number {
73
10
  return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
74
11
  }
75
12
 
76
- type Segment = { response?: number; turn?: number; failed?: boolean }
77
-
78
- function doneLine(turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string {
79
- return `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
80
- }
81
-
82
- function excerpt(item: TranscriptItem): string {
83
- switch (item.kind) {
84
- case 'user':
85
- case 'assistant_text':
86
- case 'thinking':
87
- case 'notice': {
88
- return item.text
89
- }
90
- case 'shell': {
91
- return item.shell.command
92
- }
93
- case 'tool_call': {
94
- return `${item.name}(${toolInputPreview(item.input)})`
95
- }
96
- case 'turn_result': {
97
- return doneLine(item)
98
- }
99
- case 'file_delivered': {
100
- return item.path
101
- }
102
- default: {
103
- return ''
104
- }
105
- }
106
- }
107
-
108
13
  export interface TerminalScrubberProps {
109
14
  items: readonly TranscriptItem[]
110
15
  pendingApprovals: readonly PermissionRequest[]
@@ -139,189 +44,28 @@ export function buildClusters(props: TerminalScrubberProps, railH: number): Clus
139
44
  totalSize,
140
45
  viewportH,
141
46
  } = props
142
- const marks: Mark[] = []
143
- const subagentParents = new Set<string>()
144
- for (const item of items) {
145
- const parent = parentOf(item)
146
- if (parent !== undefined) {
147
- subagentParents.add(parent)
148
- }
149
- }
150
- const rowOutcome = new Map<number, number>()
151
- items.forEach((item, index) => {
152
- if (item.kind !== 'tool_call' || parentOf(item) !== frameParentId) {
153
- return
154
- }
155
- rowOutcome.set(rowIndexFor(index), index)
156
- })
157
- let segment: Segment = {}
158
- const closeSegment = () => {
159
- const anchor = segment.response ?? segment.turn
160
- if (anchor !== undefined) {
161
- marks.push({
162
- kind: segment.failed ? 'turnFailed' : 'turn',
163
- itemIndex: anchor,
164
- rowIndex: rowIndexFor(anchor),
165
- turnIndex: segment.turn,
166
- })
167
- }
168
- segment = {}
169
- }
170
- items.forEach((item, index) => {
171
- if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
172
- marks.push({ kind: 'subagent', itemIndex: index, rowIndex: rowIndexFor(index) })
173
- }
174
- if (item.kind === 'user' && parentOf(item) === frameParentId) {
175
- closeSegment()
176
- marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
177
- } else if (item.kind === 'turn_result') {
178
- segment.turn = index
179
- segment.failed = item.isError
180
- closeSegment()
181
- } else if (item.kind === 'notice' && item.level === 'error') {
182
- marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
183
- } else if (item.kind === 'shell') {
184
- marks.push({ kind: 'shell', itemIndex: index, rowIndex: rowIndexFor(index) })
185
- } else if (
186
- item.kind === 'tool_call' &&
187
- (item.status === 'failed' || item.result?.isError === true) &&
188
- rowOutcome.get(rowIndexFor(index)) === index
189
- ) {
190
- marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
191
- } else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
192
- if (frameParentId !== undefined) {
193
- marks.push({ kind: 'turn', itemIndex: index, rowIndex: rowIndexFor(index) })
194
- return
195
- }
196
- segment.response = index
197
- }
198
- })
199
- closeSegment()
200
- for (const index of bookmarks) {
201
- if (index >= 0 && index < items.length) {
202
- marks.push({ kind: 'bookmark', itemIndex: index, rowIndex: rowIndexFor(index) })
203
- }
204
- }
47
+ const marks: Mark[] = buildMarks(items, { frameParentId, bookmarks, rowIndexFor }).map((mark) => ({
48
+ ...mark,
49
+ rowIndex: rowIndexFor(mark.itemIndex),
50
+ }))
205
51
  if (recapRow) {
206
52
  marks.push({ kind: 'recap', itemIndex: -1, rowIndex: recapRow.rowIndex })
207
53
  }
208
-
209
54
  const scale = railScale(railH, totalSize, viewportH)
210
- const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
211
- for (const mark of marks) {
55
+ const clusters = clusterMarks(marks, (mark) => {
56
+ const rowIndex = mark.rowIndex ?? rowIndexFor(mark.itemIndex)
212
57
  const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : undefined
213
- const rowH = sizeOfRow(mark.rowIndex)
58
+ const rowH = sizeOfRow(rowIndex)
214
59
  const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale))
215
- const y = Math.min(
216
- Math.max(0, railH - h),
217
- Math.round((offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale),
218
- )
219
- const lane = LANE[mark.kind]
220
- const list = lanes.get(lane) ?? []
221
- list.push({ mark, y, h })
222
- lanes.set(lane, list)
223
- }
224
- const clusters: Cluster[] = []
225
- for (const [lane, list] of lanes) {
226
- list.sort((a, b) => a.y - b.y)
227
- let current: Cluster | null = null
228
- for (const { mark, y, h } of list) {
229
- if (current && y <= current.y + current.h + 1) {
230
- current.h = Math.max(current.h, y + h - current.y)
231
- if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {
232
- current.kind = mark.kind
233
- }
234
- current.marks.push({ mark, y })
235
- } else {
236
- current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
237
- clusters.push(current)
238
- }
239
- }
240
- }
60
+ const offset = offsetOfRow(rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)
61
+ return { y: Math.min(Math.max(0, railH - h), Math.round(offset * scale)), h }
62
+ })
241
63
  if (pendingApprovals.length > 0) {
242
- clusters.push({
243
- lane: LANE.approval,
244
- kind: 'approval',
245
- y: Math.max(0, railH - MIN_MARK),
246
- h: MIN_MARK,
247
- marks: [],
248
- })
64
+ clusters.push(approvalCluster(railH))
249
65
  }
250
66
  return clusters
251
67
  }
252
68
 
253
- function peekContent(cluster: Cluster, first: Mark | undefined, { items, pendingApprovals, recapRow }: TerminalScrubberProps): ReactNode {
254
- const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
255
- let body: ReactNode = null
256
- if (cluster.kind === 'approval') {
257
- const request = pendingApprovals[0]
258
- body = request ? (
259
- <>
260
- <div data-tone="bright">{request.title ?? 'Permission required'}</div>
261
- <div className="term-scrub-ex" data-tone="fg">
262
- {`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
263
- </div>
264
- </>
265
- ) : null
266
- } else if (cluster.kind === 'recap' && !first) {
267
- body = <div data-tone="faint">※ {recapRow?.label}</div>
268
- } else if (first) {
269
- const item = items[first.itemIndex]
270
- if (first.kind === 'recap') {
271
- body = <div data-tone="faint">※ {recapRow?.label}</div>
272
- } else if (first.kind === 'turn' || first.kind === 'turnFailed') {
273
- const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
274
- body = (
275
- <>
276
- {item?.kind === 'assistant_text' ? (
277
- <div className="term-scrub-ex" data-tone="fg">
278
- <span data-tone="dim">● </span>
279
- {item.text}
280
- </div>
281
- ) : null}
282
- {turn?.kind === 'turn_result' ? (
283
- <>
284
- <div data-tone={turn.isError ? 'red' : 'faint'}>{doneLine(turn)}</div>
285
- {turn.errors?.map((message, index) => (
286
- <div key={index} data-tone="red">
287
- {message}
288
- </div>
289
- ))}
290
- </>
291
- ) : null}
292
- </>
293
- )
294
- } else if (item) {
295
- const failure =
296
- first.kind === 'toolFailed' && item.kind === 'tool_call'
297
- ? item.result?.text.split('\n').find((line) => line.trim() !== '')
298
- : undefined
299
- body = (
300
- <>
301
- <div className="term-scrub-ex" data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'red' : 'fg'}>
302
- {first.kind === 'user' ? <span data-tone="dim">{'❯ '}</span> : null}
303
- {excerpt(item)}
304
- </div>
305
- {failure ? (
306
- <div className="term-scrub-ex" data-tone="red">
307
- {failure}
308
- </div>
309
- ) : null}
310
- </>
311
- )
312
- }
313
- }
314
- return (
315
- <>
316
- <div data-tone="faint">
317
- {KIND_NAME[first?.kind ?? cluster.kind]}
318
- {more}
319
- </div>
320
- {body}
321
- </>
322
- )
323
- }
324
-
325
69
  export function TerminalScrubber(props: TerminalScrubberProps) {
326
70
  const { scrollOffset, viewportH, totalSize, onJumpToRow, interactive, fontSize, lineHeight } = props
327
71
  const stick = useStickToBottomContext()
@@ -420,7 +164,7 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
420
164
  }
421
165
  const mark = nearestMember(cluster, railY(clientY))
422
166
  if (mark) {
423
- onJumpToRow(mark.rowIndex)
167
+ onJumpToRow(mark.rowIndex ?? props.rowIndexFor(mark.itemIndex))
424
168
  }
425
169
  }
426
170
 
@@ -515,7 +259,16 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
515
259
  ))}
516
260
  {peek ? (
517
261
  <div ref={peekRef} className="term-scrub-peek" style={{ top: peek.y }}>
518
- {peekContent(peek.cluster, peek.mark, props)}
262
+ {peekContent(
263
+ {
264
+ cluster: peek.cluster,
265
+ first: peek.mark,
266
+ items: props.items,
267
+ pendingApprovals: props.pendingApprovals,
268
+ recapLabel: props.recapRow?.label,
269
+ },
270
+ TERMINAL_PEEK,
271
+ )}
519
272
  </div>
520
273
  ) : null}
521
274
  </div>
@@ -0,0 +1,29 @@
1
+ import { type FunctionComponent } from 'react'
2
+ import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { PortalScope } from './PortalScope.tsx'
5
+
6
+ export const Popover = PopoverPrimitive.Root
7
+ export const PopoverTrigger = PopoverPrimitive.Trigger
8
+
9
+ export const PopoverContent: FunctionComponent<
10
+ PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, 'align' | 'side' | 'sideOffset'>
11
+ > = ({ className, align = 'end', side = 'bottom', sideOffset = 6, ...props }) => (
12
+ <PopoverPrimitive.Portal>
13
+ <PortalScope>
14
+ <PopoverPrimitive.Positioner align={align} side={side} sideOffset={sideOffset} className="isolate z-80 outline-none">
15
+ <PopoverPrimitive.Popup
16
+ data-slot="popover-content"
17
+ className={cn(
18
+ 'rounded-md border border-border bg-surface p-2 text-fg-1 shadow-(--shadow-lg) outline-none',
19
+ 'transition-[opacity,transform] duration-(--motion-base)',
20
+ 'data-starting-style:scale-95 data-starting-style:opacity-0',
21
+ 'data-ending-style:scale-95 data-ending-style:opacity-0',
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ </PopoverPrimitive.Positioner>
27
+ </PortalScope>
28
+ </PopoverPrimitive.Portal>
29
+ )
@@ -68,3 +68,28 @@ export const SelectItem: FunctionComponent<SelectPrimitive.Item.Props> = ({ clas
68
68
  </SelectPrimitive.ItemIndicator>
69
69
  </SelectPrimitive.Item>
70
70
  )
71
+
72
+ export interface OptionSelectProps<T extends string> {
73
+ label: string
74
+ value: T
75
+ options: readonly { value: T; label: string }[]
76
+ onChange: (value: T) => void
77
+ className?: string
78
+ }
79
+
80
+ export function OptionSelect<T extends string>({ label, value, options, onChange, className }: OptionSelectProps<T>) {
81
+ return (
82
+ <Select value={value} onValueChange={(next) => onChange(next as T)}>
83
+ <SelectTrigger aria-label={label} className={className}>
84
+ <SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {options.map((option) => (
88
+ <SelectItem key={option.value} value={option.value}>
89
+ <SelectItemText>{option.label}</SelectItemText>
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ )
95
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,16 @@ export { Badge, badgeVariants, type BadgeProps } from './components/ui/Badge.tsx
3
3
  export { Card, CardContent, CardHeader, CardTitle } from './components/ui/Card.tsx'
4
4
  export { Input } from './components/ui/Input.tsx'
5
5
  export { Textarea } from './components/ui/Textarea.tsx'
6
- export { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from './components/ui/Select.tsx'
6
+ export {
7
+ OptionSelect,
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectItemText,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ type OptionSelectProps,
15
+ } from './components/ui/Select.tsx'
7
16
  export {
8
17
  AlertDialog,
9
18
  AlertDialogClose,
@@ -13,6 +22,7 @@ export {
13
22
  AlertDialogTrigger,
14
23
  } from './components/ui/AlertDialog.tsx'
15
24
  export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './components/ui/Menu.tsx'
25
+ export { Popover, PopoverContent, PopoverTrigger } from './components/ui/Popover.tsx'
16
26
  export { Dialog, DialogBody, DialogClose, DialogContent, DialogHeader, DialogRow, DialogTrigger } from './components/ui/Dialog.tsx'
17
27
  export { Tip, TooltipContent, TooltipProvider } from './components/ui/Tooltip.tsx'
18
28
  export { Toaster, toast } from './components/ui/Sonner.tsx'
@@ -51,9 +61,9 @@ export {
51
61
  type SessionVitals,
52
62
  type TerminalMetrics,
53
63
  } from './components/agent/SessionPanel.tsx'
54
- export { Transcript, type TranscriptProps } from './components/agent/Transcript.tsx'
64
+ export { TerminalTranscript, Transcript, type TerminalTranscriptProps, type TranscriptProps } from './components/agent/Transcript.tsx'
55
65
  export { TerminalSurface, type TerminalSurfaceProps } from './components/terminal/surface.tsx'
56
- export { TerminalTranscript, TerminalItemView, type TerminalTranscriptProps } from './components/terminal/TerminalTranscript.tsx'
66
+ export { TerminalItemView } from './components/terminal/TerminalTranscript.tsx'
57
67
  export { TerminalStatusLine, type TerminalStatusLineProps } from './components/terminal/StatusLine.tsx'
58
68
  export { TerminalPermissionPrompt, type TerminalPermissionPromptProps } from './components/terminal/PermissionPrompt.tsx'
59
69
  export { TerminalQuestionPrompt, type TerminalQuestionPromptProps } from './components/terminal/QuestionPrompt.tsx'
@@ -123,21 +133,49 @@ export {
123
133
  type PermissionModeSelectProps,
124
134
  } from './components/agent/PermissionModeSelect.tsx'
125
135
  export { StatusBar, type StatusBarProps } from './components/agent/StatusBar.tsx'
126
- export { ContextDialog, type ContextDialogProps } from './components/agent/ContextDialog.tsx'
136
+ export { ContextDialog, ContextPanel, type ContextDialogProps, type ContextPanelProps } from './components/agent/ContextDialog.tsx'
127
137
  export { UsageDialog, type UsageDialogProps } from './components/agent/UsageDialog.tsx'
128
138
  export { TaskList, type TaskListProps } from './components/agent/TaskList.tsx'
129
139
  export { TasksDialog, type TasksDialogProps } from './components/agent/TasksDialog.tsx'
130
140
  export { UsageMeters, useMinuteClock } from './components/agent/UsageMeters.tsx'
131
- export { SessionInfoDialog, type SessionInfoDialogProps } from './components/agent/SessionInfoDialog.tsx'
132
- export { McpDialog, type McpDialogProps } from './components/agent/McpDialog.tsx'
141
+ export {
142
+ SessionInfoDialog,
143
+ SessionInfoPanel,
144
+ type SessionInfoDialogProps,
145
+ type SessionInfoPanelProps,
146
+ } from './components/agent/SessionInfoDialog.tsx'
147
+ export {
148
+ McpDialog,
149
+ McpPanel,
150
+ McpPanelActions,
151
+ useMcpPanel,
152
+ type McpDialogProps,
153
+ type McpPanelModel,
154
+ type McpPanelProps,
155
+ } from './components/agent/McpDialog.tsx'
133
156
  export { SkillsDialog, type SkillsDialogProps } from './components/agent/SkillsDialog.tsx'
134
157
  export { HostFilesDialog, type HostFilesDialogProps } from './components/agent/HostFilesDialog.tsx'
135
158
  export { SessionList, SessionListItem, type SessionListItemProps, type SessionListProps } from './components/agent/SessionList.tsx'
136
- export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
159
+ export { SessionBrowser, SessionSearch, rowShapeClass, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
160
+ export {
161
+ STEP_DISPLAY_OPTIONS,
162
+ SessionFilters,
163
+ SessionFiltersButton,
164
+ StepDisplayControl,
165
+ type SessionFiltersButtonProps,
166
+ type SessionFiltersProps,
167
+ } from './components/agent/SessionFilters.tsx'
137
168
  export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
138
169
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
139
170
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
140
- export { type ShellStepOptions, type Step, type StepKind, StepRow, sessionSteps } from './components/agent/SessionSteps.tsx'
171
+ export {
172
+ type ShellStepOptions,
173
+ type Step,
174
+ type StepKind,
175
+ type TaskStepOptions,
176
+ StepRow,
177
+ sessionSteps,
178
+ } from './components/agent/SessionSteps.tsx'
141
179
  export {
142
180
  SUBAGENT_DISPLAY_META,
143
181
  SUBAGENT_DISPLAY_ORDER,
@@ -1,3 +1,5 @@
1
+ import { resolvePosix } from '@workerdeck/protocol'
2
+
1
3
  export type FileLink = {
2
4
  path: string
3
5
  line?: number
@@ -45,7 +47,7 @@ export function parseFileLink(href: string | undefined, cwd: string | undefined)
45
47
  return undefined
46
48
  }
47
49
 
48
- const path = target.startsWith('/') ? normalize(target) : cwd ? normalize(`${cwd}/${target}`) : undefined
50
+ const path = resolvePosix(target, cwd)
49
51
  return path ? { path, ...(line === undefined ? {} : { line }) } : undefined
50
52
  }
51
53
 
@@ -53,18 +55,3 @@ function readLine(value: string | undefined): number | undefined {
53
55
  const parsed = value === undefined ? Number.NaN : Number(value)
54
56
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
55
57
  }
56
-
57
- function normalize(path: string): string {
58
- const out: string[] = []
59
- for (const part of path.split('/')) {
60
- if (part === '' || part === '.') {
61
- continue
62
- }
63
- if (part === '..') {
64
- out.pop()
65
- continue
66
- }
67
- out.push(part)
68
- }
69
- return `/${out.join('/')}`
70
- }
@@ -0,0 +1,57 @@
1
+ import type { PermissionRequest } from '@workerdeck/protocol'
2
+ import { planFromRequest } from './plan-request.ts'
3
+ import { shellRequestPayload, shellRequestTitle, type ShellRequestPayload } from './shell-request.ts'
4
+
5
+ export type PermissionChoiceKey = 'allow' | 'deny' | 'stop'
6
+
7
+ export type PermissionChoice = {
8
+ key: PermissionChoiceKey
9
+ button: string
10
+ option: string
11
+ danger?: boolean
12
+ }
13
+
14
+ export type PermissionPromptModel = {
15
+ plan?: string
16
+ shell?: ShellRequestPayload
17
+ heading: string
18
+ question: string
19
+ description?: string
20
+ decisionReason?: string
21
+ choices: [PermissionChoice, PermissionChoice, PermissionChoice]
22
+ denyPlaceholder: string
23
+ }
24
+
25
+ type PermissionRequestLike = Pick<PermissionRequest, 'toolName' | 'input' | 'title' | 'displayName' | 'description' | 'decisionReason'>
26
+
27
+ export function permissionPromptModel(request: PermissionRequestLike): PermissionPromptModel {
28
+ const plan = planFromRequest(request)
29
+ const shell = plan ? undefined : shellRequestPayload(request)
30
+ const context = { description: request.description, decisionReason: request.decisionReason }
31
+ if (plan) {
32
+ return {
33
+ plan,
34
+ heading: 'Plan ready for review',
35
+ question: 'Ready to implement this plan?',
36
+ ...context,
37
+ choices: [
38
+ { key: 'allow', button: 'Approve plan', option: 'Approve plan' },
39
+ { key: 'deny', button: 'Keep planning', option: 'Keep planning - tell it what to change' },
40
+ { key: 'stop', button: 'Stop the turn', option: 'No, and stop the turn', danger: true },
41
+ ],
42
+ denyPlaceholder: 'What should change? (optional) - the agent keeps planning and reads this',
43
+ }
44
+ }
45
+ return {
46
+ shell,
47
+ heading: shell ? shellRequestTitle(shell) : (request.title ?? request.displayName ?? 'Permission needed'),
48
+ question: shell ? 'Do you want to let it?' : 'Do you want to proceed?',
49
+ ...context,
50
+ choices: [
51
+ { key: 'allow', button: 'Allow', option: 'Yes' },
52
+ { key: 'deny', button: 'Deny', option: 'No, and tell the agent what to do differently' },
53
+ { key: 'stop', button: 'Deny & stop', option: 'No, and stop the turn', danger: true },
54
+ ],
55
+ denyPlaceholder: 'Reason (optional) - the agent reads this and can try something else',
56
+ }
57
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react'
2
+ import type { UserQuestion } from '@workerdeck/protocol'
3
+
4
+ export type Selection = { labels: string[]; other: string; otherActive: boolean }
5
+
6
+ export const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
7
+
8
+ export function answerFor(selection: Selection): string {
9
+ const parts = [...selection.labels]
10
+ if (selection.otherActive && selection.other.trim()) {
11
+ parts.push(selection.other.trim())
12
+ }
13
+ return parts.join(', ')
14
+ }
15
+
16
+ export function toggleLabel(selection: Selection, label: string, multiSelect: boolean): Selection {
17
+ if (!multiSelect) {
18
+ return { ...selection, labels: [label], otherActive: false }
19
+ }
20
+ const labels = selection.labels.includes(label) ? selection.labels.filter((l) => l !== label) : [...selection.labels, label]
21
+ return { ...selection, labels }
22
+ }
23
+
24
+ export function toggleOther(selection: Selection, multiSelect: boolean): Selection {
25
+ const otherActive = !selection.otherActive
26
+ return { ...selection, otherActive, labels: otherActive && !multiSelect ? [] : selection.labels }
27
+ }
28
+
29
+ export function answersFor(questions: readonly UserQuestion[], selections: readonly Selection[]): Record<string, string> {
30
+ const answers: Record<string, string> = {}
31
+ questions.forEach((question, index) => {
32
+ answers[question.question] = answerFor(selections[index] ?? EMPTY_SELECTION)
33
+ })
34
+ return answers
35
+ }
36
+
37
+ export interface QuestionAnswers {
38
+ selections: Selection[]
39
+ selectionFor: (index: number) => Selection
40
+ answered: (index: number) => boolean
41
+ complete: boolean
42
+ update: (index: number, patch: Partial<Selection>) => void
43
+ toggle: (index: number, label: string) => void
44
+ toggleOther: (index: number) => void
45
+ answers: () => Record<string, string>
46
+ }
47
+
48
+ export function useQuestionAnswers(questions: readonly UserQuestion[]): QuestionAnswers {
49
+ const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY_SELECTION))
50
+ const selectionFor = (index: number) => selections[index] ?? EMPTY_SELECTION
51
+ const answered = (index: number) => answerFor(selectionFor(index)) !== ''
52
+ const edit = (index: number, change: (selection: Selection) => Selection) =>
53
+ setSelections((prev) => questions.map((_, i) => (i === index ? change(prev[i] ?? EMPTY_SELECTION) : (prev[i] ?? EMPTY_SELECTION))))
54
+ const multiSelect = (index: number) => questions[index]?.multiSelect === true
55
+ return {
56
+ selections,
57
+ selectionFor,
58
+ answered,
59
+ complete: questions.every((_, index) => answered(index)),
60
+ update: (index, patch) => edit(index, (selection) => ({ ...selection, ...patch })),
61
+ toggle: (index, label) => edit(index, (selection) => toggleLabel(selection, label, multiSelect(index))),
62
+ toggleOther: (index) => edit(index, (selection) => toggleOther(selection, multiSelect(index))),
63
+ answers: () => answersFor(questions, selections),
64
+ }
65
+ }