@workerdeck/ui 3.0.1 → 3.2.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,6 +1,7 @@
1
- import { useCallback, useEffect, useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import type { WorkerDeckClient } from '@workerdeck/client'
3
- import type { McpServerActionRequest, McpServerStatusInfo, McpServerToolInfo } from '@workerdeck/protocol'
3
+ import { useAsync } from '@workerdeck/react'
4
+ import { errorMessage, type McpServerActionRequest, type McpServerStatusInfo, type McpServerToolInfo } from '@workerdeck/protocol'
4
5
  import { ChevronLeft, ChevronRight, Power, PowerOff, RotateCw } from 'lucide-react'
5
6
  import { Badge, type BadgeProps } from '../ui/Badge.tsx'
6
7
  import { Button } from '../ui/Button.tsx'
@@ -17,6 +18,27 @@ export interface McpDialogProps {
17
18
  className?: string
18
19
  }
19
20
 
21
+ export interface McpPanelModel {
22
+ servers: McpServerStatusInfo[] | undefined
23
+ server: McpServerStatusInfo | undefined
24
+ tool: McpServerToolInfo | undefined
25
+ loading: boolean
26
+ busyServer: string | undefined
27
+ error: string | undefined
28
+ title: string
29
+ description: string | undefined
30
+ selectServer: (name: string | undefined) => void
31
+ selectTool: (name: string | undefined) => void
32
+ back: (() => void) | undefined
33
+ reload: () => void
34
+ act: (name: string, action: McpServerActionRequest['action']) => Promise<void>
35
+ }
36
+
37
+ export interface McpPanelProps {
38
+ model: McpPanelModel
39
+ canManageServers?: boolean
40
+ }
41
+
20
42
  const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
21
43
  connected: 'success',
22
44
  failed: 'danger',
@@ -26,94 +48,106 @@ const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
26
48
  }
27
49
 
28
50
  export function McpDialog({ client, sessionId, open, onOpenChange, canManageServers = true, className }: McpDialogProps) {
29
- const [servers, setServers] = useState<McpServerStatusInfo[] | undefined>()
30
- const [error, setError] = useState<string | undefined>()
31
- const [loading, setLoading] = useState(false)
51
+ const mcp = useMcpPanel(client, sessionId, open)
52
+ return (
53
+ <Dialog open={open} onOpenChange={onOpenChange}>
54
+ <DialogContent className={className}>
55
+ <DialogHeader title={mcp.title} description={mcp.description} actions={<McpPanelActions model={mcp} />} />
56
+ <DialogBody>
57
+ <McpPanel model={mcp} canManageServers={canManageServers} />
58
+ </DialogBody>
59
+ </DialogContent>
60
+ </Dialog>
61
+ )
62
+ }
63
+
64
+ export function useMcpPanel(client: WorkerDeckClient | undefined, sessionId: string | undefined, active = true): McpPanelModel {
65
+ const listed = useAsync(() => client!.listMcpServers(sessionId!), [client, sessionId], { enabled: active && !!client && !!sessionId })
66
+ const [actError, setActError] = useState<string | undefined>()
32
67
  const [busyServer, setBusyServer] = useState<string | undefined>()
33
68
  const [selectedServer, setSelectedServer] = useState<string | undefined>()
34
69
  const [selectedTool, setSelectedTool] = useState<string | undefined>()
35
70
 
36
- const load = useCallback(async () => {
37
- if (!sessionId) {
38
- return
39
- }
40
- setLoading(true)
41
- setError(undefined)
42
- try {
43
- setServers(await client.listMcpServers(sessionId))
44
- } catch (e) {
45
- setError(e instanceof Error ? e.message : 'Could not read MCP status')
46
- } finally {
47
- setLoading(false)
48
- }
49
- }, [client, sessionId])
50
-
51
71
  useEffect(() => {
52
- if (!open) {
53
- return
72
+ if (active) {
73
+ setSelectedServer(undefined)
74
+ setSelectedTool(undefined)
75
+ setActError(undefined)
54
76
  }
55
- setSelectedServer(undefined)
56
- setSelectedTool(undefined)
57
- void load()
58
- }, [open, load])
59
-
60
- const act = async (name: string, action: McpServerActionRequest['action']) => {
61
- if (!sessionId) {
62
- return
63
- }
64
- setBusyServer(name)
65
- setError(undefined)
66
- try {
67
- setServers(await client.mcpServerAction(sessionId, name, action))
68
- } catch (e) {
69
- setError(e instanceof Error ? e.message : `Could not ${action} ${name}`)
70
- } finally {
71
- setBusyServer(undefined)
72
- }
73
- }
77
+ }, [active, client, sessionId])
74
78
 
79
+ const servers = listed.data
75
80
  const server = servers?.find((s) => s.name === selectedServer)
76
81
  const tool = server?.tools?.find((t) => t.name === selectedTool)
77
- const title = tool?.name ?? server?.name ?? 'MCP servers'
82
+ return {
83
+ servers,
84
+ server,
85
+ tool,
86
+ loading: listed.loading,
87
+ busyServer,
88
+ error: actError ?? (listed.error === undefined ? undefined : errorMessage(listed.error, 'Could not read MCP status')),
89
+ title: tool?.name ?? server?.name ?? 'MCP servers',
90
+ description: tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined,
91
+ selectServer: setSelectedServer,
92
+ selectTool: setSelectedTool,
93
+ back: selectedServer ? () => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined)) : undefined,
94
+ reload: () => {
95
+ setActError(undefined)
96
+ void listed.reload()
97
+ },
98
+ act: async (name, action) => {
99
+ if (!client || !sessionId) {
100
+ return
101
+ }
102
+ setBusyServer(name)
103
+ setActError(undefined)
104
+ try {
105
+ listed.setData(await client.mcpServerAction(sessionId, name, action))
106
+ } catch (e) {
107
+ setActError(errorMessage(e, `Could not ${action} ${name}`))
108
+ } finally {
109
+ setBusyServer(undefined)
110
+ }
111
+ },
112
+ }
113
+ }
78
114
 
115
+ export function McpPanelActions({ model }: { model: McpPanelModel }) {
116
+ if (model.back) {
117
+ return (
118
+ <Button variant="ghost" size="xs" onClick={model.back}>
119
+ <ChevronLeft className="size-3.5" />
120
+ Back
121
+ </Button>
122
+ )
123
+ }
79
124
  return (
80
- <Dialog open={open} onOpenChange={onOpenChange}>
81
- <DialogContent className={className}>
82
- <DialogHeader
83
- title={title}
84
- description={tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined}
85
- actions={
86
- selectedServer ? (
87
- <Button variant="ghost" size="xs" onClick={() => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined))}>
88
- <ChevronLeft className="size-3.5" />
89
- Back
90
- </Button>
91
- ) : (
92
- <Button variant="ghost" size="xs" onClick={() => void load()} disabled={loading}>
93
- {loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
94
- Refresh
95
- </Button>
96
- )
97
- }
125
+ <Button variant="ghost" size="xs" onClick={model.reload} disabled={model.loading}>
126
+ {model.loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
127
+ Refresh
128
+ </Button>
129
+ )
130
+ }
131
+
132
+ export function McpPanel({ model, canManageServers = true }: McpPanelProps) {
133
+ const { error, servers, server, tool } = model
134
+ return (
135
+ <>
136
+ {error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
137
+ {tool ? (
138
+ <ToolView tool={tool} />
139
+ ) : server ? (
140
+ <ServerView
141
+ server={server}
142
+ busy={model.busyServer === server.name}
143
+ canManage={canManageServers}
144
+ onAct={(action) => void model.act(server.name, action)}
145
+ onSelectTool={model.selectTool}
98
146
  />
99
- <DialogBody>
100
- {error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
101
- {tool ? (
102
- <ToolView tool={tool} />
103
- ) : server ? (
104
- <ServerView
105
- server={server}
106
- busy={busyServer === server.name}
107
- canManage={canManageServers}
108
- onAct={(action) => void act(server.name, action)}
109
- onSelectTool={setSelectedTool}
110
- />
111
- ) : error && !servers ? null : (
112
- <ServerList servers={servers} loading={loading} onSelect={setSelectedServer} />
113
- )}
114
- </DialogBody>
115
- </DialogContent>
116
- </Dialog>
147
+ ) : error && !servers ? null : (
148
+ <ServerList servers={servers} loading={model.loading} onSelect={model.selectServer} />
149
+ )}
150
+ </>
117
151
  )
118
152
  }
119
153
 
@@ -5,8 +5,8 @@ import { Button } from '../ui/Button.tsx'
5
5
  import { Input } from '../ui/Input.tsx'
6
6
  import { cn } from '../../lib/utils.ts'
7
7
  import { toolInputPreview } from '../../lib/format.ts'
8
- import { planFromRequest } from '../../lib/plan-request.ts'
9
- import { shellRequestLines, shellRequestPayload, shellRequestTitle } from '../../lib/shell-request.ts'
8
+ import { permissionPromptModel } from '../../lib/permission-prompt.ts'
9
+ import { shellRequestLines } from '../../lib/shell-request.ts'
10
10
  import { toolIcon } from '../../lib/tool-icon.ts'
11
11
  import { Response } from './Response.tsx'
12
12
 
@@ -29,8 +29,9 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
29
29
  setDenying(false)
30
30
  }
31
31
 
32
- const plan = planFromRequest(request)
33
- const shell = plan ? undefined : shellRequestPayload(request)
32
+ const model = permissionPromptModel(request)
33
+ const { plan, shell } = model
34
+ const [allow, keep, stop] = model.choices
34
35
  const summary = toolInputPreview(request.input)
35
36
  const ToolIcon = toolIcon(request.toolName)
36
37
 
@@ -39,15 +40,9 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
39
40
  <div className="flex items-start gap-2.5">
40
41
  <Hand className="mt-0.5 size-4 shrink-0 text-warning" />
41
42
  <div className="min-w-0 flex-1">
42
- <div className="text-body-sm font-medium text-fg-1">
43
- {plan
44
- ? 'Plan ready for review'
45
- : shell
46
- ? shellRequestTitle(shell)
47
- : (request.title ?? request.displayName ?? 'Permission needed')}
48
- </div>
49
- {request.description ? <div className="mt-0.5 text-label text-fg-3">{request.description}</div> : null}
50
- {request.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{request.decisionReason}</div> : null}
43
+ <div className="text-body-sm font-medium text-fg-1">{model.heading}</div>
44
+ {model.description ? <div className="mt-0.5 text-label text-fg-3">{model.description}</div> : null}
45
+ {model.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{model.decisionReason}</div> : null}
51
46
  {plan ? (
52
47
  <div className="mt-1.5 max-h-72 overflow-y-auto rounded-md bg-code-bg px-2.5 py-2 text-body-sm text-fg-2">
53
48
  <Response>{plan}</Response>
@@ -84,13 +79,13 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
84
79
  </div>
85
80
  <div className="flex shrink-0 flex-wrap justify-end gap-1.5">
86
81
  <Button size="sm" onClick={() => onApprove(request.id)}>
87
- {plan ? 'Approve plan' : 'Allow'}
82
+ {allow.button}
88
83
  </Button>
89
84
  <Button size="sm" variant="outline" onClick={() => setDenying((v) => !v)}>
90
- {plan ? 'Keep planning' : 'Deny'}
85
+ {keep.button}
91
86
  </Button>
92
87
  <Button size="sm" variant="outline" onClick={() => deny(true)}>
93
- {plan ? 'Stop the turn' : 'Deny & stop'}
88
+ {stop.button}
94
89
  </Button>
95
90
  </div>
96
91
  </div>
@@ -108,18 +103,14 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
108
103
  setDenying(false)
109
104
  }
110
105
  }}
111
- placeholder={
112
- plan
113
- ? 'What should change? (optional) - the agent keeps planning and reads this'
114
- : 'Reason (optional) - the agent reads this and can try something else'
115
- }
106
+ placeholder={model.denyPlaceholder}
116
107
  className="h-7 flex-1"
117
108
  />
118
109
  <Button size="sm" variant="outline" onClick={() => setDenying(false)}>
119
110
  Cancel
120
111
  </Button>
121
112
  <Button size="sm" variant="destructive" onClick={() => deny(false)}>
122
- {plan ? 'Keep planning' : 'Deny'}
113
+ {keep.button}
123
114
  </Button>
124
115
  </div>
125
116
  ) : null}
@@ -1,4 +1,3 @@
1
- import { useState } from 'react'
2
1
  import { parseUserQuestions, type PermissionRequest, type QuestionBehavior } from '@workerdeck/protocol'
3
2
 
4
3
  // Re-exported, not redefined: the parse moved to protocol so the CLI's Live Activity builder could
@@ -7,6 +6,7 @@ import { MessageCircleQuestion, X } from 'lucide-react'
7
6
  import { Badge } from '../ui/Badge.tsx'
8
7
  import { Button } from '../ui/Button.tsx'
9
8
  import { Textarea } from '../ui/Textarea.tsx'
9
+ import { useQuestionAnswers } from '../../lib/question-answers.ts'
10
10
  import { cn } from '../../lib/utils.ts'
11
11
 
12
12
  export { parseUserQuestions }
@@ -23,18 +23,6 @@ export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
23
23
  { value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
24
24
  ]
25
25
 
26
- type Selection = { labels: string[]; other: string; otherActive: boolean }
27
-
28
- const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
29
-
30
- function answerFor(selection: Selection): string {
31
- const parts = [...selection.labels]
32
- if (selection.otherActive && selection.other.trim()) {
33
- parts.push(selection.other.trim())
34
- }
35
- return parts.join(', ')
36
- }
37
-
38
26
  export interface QuestionPromptProps {
39
27
  request: PermissionRequest
40
28
  onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
@@ -44,32 +32,9 @@ export interface QuestionPromptProps {
44
32
 
45
33
  export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
46
34
  const questions = parseUserQuestions(request.input)
47
- const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY_SELECTION))
48
-
49
- const update = (index: number, patch: Partial<Selection>) => {
50
- setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
51
- }
52
-
53
- const toggle = (index: number, label: string, multiSelect: boolean) => {
54
- const current = selections[index] ?? EMPTY_SELECTION
55
- if (multiSelect) {
56
- update(index, {
57
- labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
58
- })
59
- } else {
60
- update(index, { labels: current.labels[0] === label ? [] : [label], otherActive: false })
61
- }
62
- }
63
-
64
- const complete = questions.every((_, i) => answerFor(selections[i] ?? EMPTY_SELECTION) !== '')
35
+ const { selectionFor, update, toggle, toggleOther, complete, answers } = useQuestionAnswers(questions)
65
36
 
66
- const submit = () => {
67
- const answers: Record<string, string> = {}
68
- questions.forEach((q, i) => {
69
- answers[q.question] = answerFor(selections[i] ?? EMPTY_SELECTION)
70
- })
71
- onAnswer(request.id, { ...request.input, answers })
72
- }
37
+ const submit = () => onAnswer(request.id, { ...request.input, answers: answers() })
73
38
 
74
39
  return (
75
40
  <div data-slot="question-prompt" className={cn('rounded-lg border border-info/40 bg-info-bg p-3', className)}>
@@ -77,7 +42,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
77
42
  <MessageCircleQuestion className="mt-0.5 size-4 shrink-0 text-info" />
78
43
  <div className="flex min-w-0 flex-1 flex-col gap-3">
79
44
  {questions.map((q, index) => {
80
- const selection = selections[index] ?? EMPTY_SELECTION
45
+ const selection = selectionFor(index)
81
46
  return (
82
47
  <div key={index} className="flex flex-col gap-1.5">
83
48
  <div className="flex items-center gap-2">
@@ -91,7 +56,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
91
56
  <button
92
57
  key={option.label}
93
58
  type="button"
94
- onClick={() => toggle(index, option.label, q.multiSelect === true)}
59
+ onClick={() => toggle(index, option.label)}
95
60
  className={cn(
96
61
  'rounded-md border px-2.5 py-1.5 text-left transition-colors',
97
62
  selected ? 'border-info bg-bg' : 'border-border bg-bg/50 hover:border-border-strong hover:bg-bg',
@@ -110,7 +75,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
110
75
  <div className="flex items-center gap-2">
111
76
  <button
112
77
  type="button"
113
- onClick={() => update(index, { otherActive: !selection.otherActive })}
78
+ onClick={() => toggleOther(index)}
114
79
  className={cn(
115
80
  'shrink-0 rounded-md border px-2.5 py-1.5 text-body-sm font-medium transition-colors',
116
81
  selection.otherActive
@@ -1,19 +1,17 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { PermissionRequest } from '@workerdeck/protocol'
3
3
  import type { TranscriptItem } from '@workerdeck/react'
4
- import { toolInputPreview } from '../../lib/format.ts'
5
4
  import { cn } from '../../lib/utils.ts'
6
5
  import {
7
6
  approvalCluster,
8
7
  buildMarks,
9
8
  clusterMarks,
10
- doneLine,
11
- excerpt,
12
- KIND_NAME,
13
9
  nearestMember,
10
+ proportionalPlacement,
14
11
  type Cluster,
15
12
  type Mark,
16
13
  } from './scrubber-marks.ts'
14
+ import { CARD_PEEK, peekContent } from './scrubber-peek.tsx'
17
15
 
18
16
  export interface ScrubberProps {
19
17
  items: readonly TranscriptItem[]
@@ -26,74 +24,6 @@ export interface ScrubberProps {
26
24
  className?: string
27
25
  }
28
26
 
29
- function peekContent(
30
- cluster: Cluster,
31
- first: Mark | undefined,
32
- items: readonly TranscriptItem[],
33
- pendingApprovals: readonly PermissionRequest[],
34
- ): ReactNode {
35
- const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
36
- let body: ReactNode = null
37
- if (cluster.kind === 'approval') {
38
- const request = pendingApprovals[0]
39
- body = request ? (
40
- <>
41
- <div>{request.title ?? 'Permission required'}</div>
42
- <div className="wd-scrub-ex" data-tone="muted">
43
- {`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
44
- </div>
45
- </>
46
- ) : null
47
- } else if (first && first.kind !== 'recap') {
48
- const item = items[first.itemIndex]
49
- if (first.kind === 'turn' || first.kind === 'turnFailed') {
50
- const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
51
- body = (
52
- <>
53
- {item?.kind === 'assistant_text' ? <div className="wd-scrub-ex">{item.text}</div> : null}
54
- {turn?.kind === 'turn_result' ? (
55
- <>
56
- <div data-tone={turn.isError ? 'danger' : 'muted'}>{doneLine(turn)}</div>
57
- {turn.errors?.map((message, index) => (
58
- <div key={index} data-tone="danger">
59
- {message}
60
- </div>
61
- ))}
62
- </>
63
- ) : null}
64
- </>
65
- )
66
- } else if (item) {
67
- const failure =
68
- first.kind === 'toolFailed' && item.kind === 'tool_call'
69
- ? item.result?.text.split('\n').find((line) => line.trim() !== '')
70
- : undefined
71
- body = (
72
- <>
73
- <div className="wd-scrub-ex" data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'danger' : undefined}>
74
- {first.kind === 'user' ? <span data-tone="muted">{'❯ '}</span> : null}
75
- {excerpt(item)}
76
- </div>
77
- {failure ? (
78
- <div className="wd-scrub-ex" data-tone="danger">
79
- {failure}
80
- </div>
81
- ) : null}
82
- </>
83
- )
84
- }
85
- }
86
- return (
87
- <>
88
- <div data-tone="muted">
89
- {KIND_NAME[first?.kind ?? cluster.kind]}
90
- {more}
91
- </div>
92
- {body}
93
- </>
94
- )
95
- }
96
-
97
27
  export function Scrubber({
98
28
  items,
99
29
  pendingApprovals,
@@ -138,7 +68,8 @@ export function Scrubber({
138
68
  if (railH <= 0) {
139
69
  return []
140
70
  }
141
- const built = clusterMarks(buildMarks(items, { frameParentId, bookmarks, recapItemIndex }), railH, items.length)
71
+ const marks = buildMarks(items, { frameParentId, bookmarks, recapItemIndex })
72
+ const built = clusterMarks(marks, proportionalPlacement(railH, items.length))
142
73
  if (pendingApprovals.length > 0) {
143
74
  built.push(approvalCluster(railH))
144
75
  }
@@ -191,7 +122,7 @@ export function Scrubber({
191
122
  ))}
192
123
  {peek ? (
193
124
  <div ref={peekRef} className="wd-scrub-peek" style={{ top: peek.y }}>
194
- {peekContent(peek.cluster, peek.mark, items, pendingApprovals)}
125
+ {peekContent({ cluster: peek.cluster, first: peek.mark, items, pendingApprovals }, CARD_PEEK)}
195
126
  </div>
196
127
  ) : null}
197
128
  </div>