@workerdeck/ui 2.15.0 → 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 (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -17,7 +17,6 @@ import {
17
17
  usageInfos,
18
18
  type ModelOption,
19
19
  type PermissionMode,
20
- type PermissionRequest,
21
20
  type RateLimitInfo,
22
21
  type SessionTask,
23
22
  type ShellInfo,
@@ -34,95 +33,50 @@ import {
34
33
  useToolCallHost,
35
34
  type ClientToolHandler,
36
35
  type ConnectionState,
37
- type ProducedFileRef,
38
36
  type TranscriptState,
39
37
  type UseToolCallHostOptions,
40
38
  } from '@workerdeck/react'
41
- import { ChartPie, FolderTree, Gauge, Info, MoreHorizontal, Plug, Sparkles, TriangleAlert, X } from 'lucide-react'
39
+ import { ChartPie, FolderTree, Gauge, Info, MoreHorizontal, Plug, Sparkles, TriangleAlert, X, type LucideIcon } from 'lucide-react'
42
40
  import { cn } from '../../lib/utils.ts'
43
41
  import { Button } from '../ui/Button.tsx'
44
42
  import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
45
43
  import { Composer, type ComposerHandle } from './Composer.tsx'
46
44
  import {
45
+ buildClientCommands,
47
46
  composerCommandRows,
48
47
  matchClientCommand,
49
48
  mergeComposerRows,
50
49
  skillPrompt,
51
- type ClientCommand,
52
50
  type ComposerCommandRow,
53
51
  } from './composer-commands.ts'
54
52
  import { ContextDialog } from './ContextDialog.tsx'
55
- import { TasksDialog } from './TasksDialog.tsx'
56
53
  import { HostFilesDialog } from './HostFilesDialog.tsx'
57
54
  import { McpDialog } from './McpDialog.tsx'
58
55
  import { SkillsDialog } from './SkillsDialog.tsx'
59
56
  import { ModelSelect } from './ModelSelect.tsx'
60
57
  import { PermissionModeSelect, permissionModeChoices, type PermissionModeChoice } from './PermissionModeSelect.tsx'
61
- import { PermissionPrompt } from './PermissionPrompt.tsx'
62
58
  import { SubagentStrip } from './SubagentStrip.tsx'
63
59
  import { useSubagentFrame } from './use-subagent-frame.ts'
64
60
  import { ShellStrip } from './ShellStrip.tsx'
65
61
  import { ShellTerminal } from './ShellTerminal.tsx'
66
62
  import { useShellFrame } from './use-shell-frame.ts'
67
- import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
68
- import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
69
- import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
70
- import { TerminalSurface } from '../terminal/surface.tsx'
71
63
  import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from '../terminal/affordances.tsx'
72
- import { FileLinkProvider, type FileLinkOpener } from '../terminal/file-link.tsx'
73
-
64
+ import type { FileLinkOpener } from '../terminal/file-link.tsx'
65
+ import { ApprovalPrompts, type ApprovalPromptProps } from './ApprovalPrompts.tsx'
66
+ import { SessionPanelProviders } from './session-panel-providers.tsx'
67
+ import { useCatchUp } from './use-catch-up.ts'
68
+ import { useHostImage } from './use-host-image.ts'
74
69
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
75
70
  import { StatusBar } from './StatusBar.tsx'
76
71
  import { Transcript } from './Transcript.tsx'
77
- import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
78
- import { ShellActionsProvider } from './shell-actions.tsx'
79
- import { ToolTitleProvider } from './tool-titles.tsx'
80
- import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
81
- import {
82
- TranscriptDensityProvider,
83
- TranscriptVariantProvider,
84
- type TranscriptDensity,
85
- type TranscriptFont,
86
- type TranscriptVariant,
87
- } from './transcript-variant.tsx'
72
+ import { useToolResultImages } from './tool-result-image.tsx'
73
+ import { ImageViewerProvider } from './image-viewer.tsx'
74
+ import type { TranscriptFont, TranscriptVariant } from './transcript-variant.tsx'
88
75
  import { UsageDialog } from './UsageDialog.tsx'
89
76
 
90
- function PromptSurface({
91
- terminal,
92
- metrics,
93
- affordances,
94
- children,
95
- }: {
96
- terminal: boolean
97
- metrics?: TerminalMetrics
98
- affordances?: TerminalAffordances | boolean
99
- children: ReactNode
100
- }) {
101
- if (!terminal) {
102
- return <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2">{children}</div>
103
- }
104
- return (
105
- <TerminalSurface
106
- fontSize={metrics?.fontSize}
107
- lineHeight={metrics?.lineHeight}
108
- affordances={affordances}
109
- bleed="1ch"
110
- className="term-transcript"
111
- >
112
- {children}
113
- </TerminalSurface>
114
- )
115
- }
116
-
117
77
  export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
118
78
 
119
- // Deliberately the built-in prompts' own callback shapes, so `PermissionPrompt` and
120
- // `TerminalPermissionPrompt` stay drop-in fallbacks for an input a host declines to draw.
121
- export interface ApprovalPromptProps {
122
- request: PermissionRequest
123
- onApprove: (requestId: string, updatedInput?: Record<string, unknown>) => void
124
- onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
125
- }
79
+ export type { ApprovalPromptProps }
126
80
 
127
81
  export interface SessionPanelProps {
128
82
  client: WorkerDeckClient
@@ -155,7 +109,6 @@ export interface SessionPanelProps {
155
109
  subagents?: SubagentInfo[]
156
110
  shells?: ShellInfo[]
157
111
  stickyPrompt?: boolean
158
- transcriptDensity?: TranscriptDensity
159
112
  transcriptFont?: TranscriptFont
160
113
  controlsSurface?: 'internal' | 'external' | 'status'
161
114
  onControls?: (controls: SessionControls | undefined) => void
@@ -198,6 +151,16 @@ const INTERACTIVE = [
198
151
 
199
152
  export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills' | 'tasks'
200
153
  type Panel = SessionSurfacePanel
154
+ type MenuPanel = Exclude<Panel, 'tasks'>
155
+
156
+ const PANEL_ITEMS: { panel: MenuPanel; label: string; Icon: LucideIcon }[] = [
157
+ { panel: 'context', label: 'Context', Icon: ChartPie },
158
+ { panel: 'usage', label: 'Usage', Icon: Gauge },
159
+ { panel: 'info', label: 'Session info', Icon: Info },
160
+ { panel: 'mcp', label: 'MCP servers', Icon: Plug },
161
+ { panel: 'skills', label: 'Skills', Icon: Sparkles },
162
+ { panel: 'files', label: 'Project files', Icon: FolderTree },
163
+ ]
201
164
 
202
165
  export type SessionVitals = {
203
166
  status: TranscriptState['status']
@@ -233,7 +196,6 @@ export function SessionPanel({
233
196
  onOpenPanel,
234
197
  onVitals,
235
198
  transcriptVariant = 'cards',
236
- transcriptDensity = 'comfortable',
237
199
  transcriptFont = 'sans',
238
200
  affordances,
239
201
  terminalMetrics,
@@ -264,8 +226,10 @@ export function SessionPanel({
264
226
  fontSize,
265
227
  className,
266
228
  }: SessionPanelProps) {
267
- const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
268
- const effectiveTermLineHeight = terminalMetrics?.lineHeight ?? (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
229
+ const cell = {
230
+ fontSize: terminalMetrics?.fontSize ?? fontSize,
231
+ lineHeight: terminalMetrics?.lineHeight ?? (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined),
232
+ }
269
233
 
270
234
  const external = panelSurface === 'external'
271
235
  const statusExternal = statusSurface === 'external'
@@ -273,8 +237,6 @@ export function SessionPanel({
273
237
  const controlsExternal = controlsSurface === 'external' || controlsInStatus
274
238
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
275
239
  const [panel, setPanel] = useState<Panel | undefined>()
276
- const [showCompletedTasks, setShowCompletedTasks] = useState(false)
277
- const [taskReveal, setTaskReveal] = useState<{ toolUseId: string; nonce: number } | undefined>()
278
240
  const {
279
241
  state,
280
242
  connection,
@@ -298,6 +260,7 @@ export function SessionPanel({
298
260
  loadShellOutput,
299
261
  verifyShell,
300
262
  killShell,
263
+ setShellAgentWrite,
301
264
  } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
302
265
  const {
303
266
  shellId: framedShellId,
@@ -307,9 +270,10 @@ export function SessionPanel({
307
270
  shell: framedShell,
308
271
  label: framedShellLabel,
309
272
  } = useShellFrame({ sessionId, items: state.items, session: state.session, shells, reveal, openShell, onShellChange })
273
+ const agentWrite = state.session?.shellAgentWrite !== undefined ? setShellAgentWrite : undefined
310
274
  const shellActions = useMemo(
311
- () => ({ loadOutput: loadShellOutput, verify: verifyShell, kill: killShell, open: enterShell }),
312
- [loadShellOutput, verifyShell, killShell, enterShell],
275
+ () => ({ loadOutput: loadShellOutput, verify: verifyShell, kill: killShell, open: enterShell, agentWrite }),
276
+ [loadShellOutput, verifyShell, killShell, enterShell, agentWrite],
313
277
  )
314
278
  useEffect(() => setProtocolError(undefined), [sessionId])
315
279
  const {
@@ -331,12 +295,7 @@ export function SessionPanel({
331
295
  return returnReveal ?? shellReturnReveal
332
296
  }, [returnReveal, shellReturnReveal])
333
297
 
334
- const [caughtUp, setCaughtUp] = useState(false)
335
- useEffect(() => {
336
- setCaughtUp(false)
337
- }, [sessionId])
338
- const [catchUpMark] = useState(unseen)
339
- const catchUp = caughtUp ? undefined : catchUpMark
298
+ const { mark: catchUp, dismiss: dismissCatchUp } = useCatchUp(sessionId, unseen)
340
299
  const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
341
300
 
342
301
  const openPanel = useCallback(
@@ -391,66 +350,11 @@ export function SessionPanel({
391
350
  [state.checklist, subagents, state.session?.subagents],
392
351
  )
393
352
 
394
- const clientCommands = useMemo((): ClientCommand[] => {
395
- const modes = capabilities.permissionModes
396
- const built: ClientCommand[] = []
397
- if (models.length > 0) {
398
- built.push({
399
- name: 'model',
400
- description: 'Switch the model for this session',
401
- argumentHint: '<model>',
402
- requiresArgs: true,
403
- run: (args) => {
404
- const wanted = args.split(/\s+/)[0]
405
- if (!wanted) {
406
- return false
407
- }
408
- setModel(wanted)
409
- return true
410
- },
411
- })
412
- }
413
- if (modes.length > 1) {
414
- built.push({
415
- name: 'permissions',
416
- description: `Set the permission mode (${modes.join(', ')})`,
417
- argumentHint: '<mode>',
418
- requiresArgs: true,
419
- run: (args) => {
420
- const wanted = args.split(/\s+/)[0] as PermissionMode
421
- if (!modes.includes(wanted)) {
422
- return false
423
- }
424
- setPermissionMode(wanted)
425
- return true
426
- },
427
- })
428
- }
429
- if (capabilities.clearContext) {
430
- built.push({
431
- name: 'clear',
432
- description: 'Clear the conversation - the session keeps running and the old one stays resumable',
433
- run: () => {
434
- clearContext()
435
- return true
436
- },
437
- })
438
- }
439
- if (capabilities.mcpStatus) {
440
- built.push({ name: 'mcp', description: 'MCP servers and their status', run: () => (openPanel('mcp'), true) })
441
- }
442
- if (capabilities.contextUsage) {
443
- built.push({ name: 'context', description: 'Context window usage', run: () => (openPanel('context'), true) })
444
- }
445
- built.push({ name: 'status', description: 'Session details', run: () => (openPanel('info'), true) })
446
- if (capabilities.rateLimits) {
447
- built.push({ name: 'usage', description: 'Rate limits and spend', run: () => (openPanel('usage'), true) })
448
- }
449
- if (capabilities.skillsList) {
450
- built.push({ name: 'skills', description: 'Browse the skills this session can use', run: () => (openPanel('skills'), true) })
451
- }
452
- return built
453
- }, [capabilities, models.length, openPanel, clearContext, setModel, setPermissionMode])
353
+ const hasModels = models.length > 0
354
+ const clientCommands = useMemo(
355
+ () => buildClientCommands({ capabilities, hasModels, setModel, setPermissionMode, clearContext, openPanel }),
356
+ [capabilities, hasModels, openPanel, clearContext, setModel, setPermissionMode],
357
+ )
454
358
 
455
359
  const composerCommands = useMemo(
456
360
  () =>
@@ -471,45 +375,34 @@ export function SessionPanel({
471
375
  () => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
472
376
  [capabilities.permissionModes, state.session?.canBypassPermissions],
473
377
  )
474
- useEffect(() => {
475
- onVitalsRef.current?.({
476
- status: state.status,
477
- connection,
478
- engine: state.engine,
479
- capabilities: state.capabilities,
480
- model: vitalsModel,
481
- models,
482
- permissionMode: state.permissionMode,
483
- permissionModes,
484
- skills: state.skills,
485
- composerCommands,
486
- cwd: state.cwd,
487
- contextUsage: state.contextUsage,
488
- tasks,
489
- rateLimits,
490
- rateLimitsUpdatedAt: usageUpdatedAt,
491
- itemCount: state.items.length,
492
- totalCostUsd: state.totalCostUsd,
493
- })
494
- }, [
495
- state.status,
378
+ const vitals: SessionVitals = {
379
+ status: state.status,
496
380
  connection,
497
- state.engine,
498
- state.capabilities,
499
- vitalsModel,
381
+ engine: state.engine,
382
+ capabilities: state.capabilities,
383
+ model: vitalsModel,
500
384
  models,
501
- state.permissionMode,
385
+ permissionMode: state.permissionMode,
502
386
  permissionModes,
503
- state.skills,
387
+ skills: state.skills,
504
388
  composerCommands,
505
- state.cwd,
506
- state.contextUsage,
389
+ cwd: state.cwd,
390
+ contextUsage: state.contextUsage,
507
391
  tasks,
508
392
  rateLimits,
509
- usageUpdatedAt,
510
- state.items.length,
511
- state.totalCostUsd,
512
- ])
393
+ rateLimitsUpdatedAt: usageUpdatedAt,
394
+ itemCount: state.items.length,
395
+ totalCostUsd: state.totalCostUsd,
396
+ }
397
+ const lastVitals = useRef<SessionVitals | undefined>(undefined)
398
+ // Every render, compared field by field: a streamed token changes none of them and never reaches the host.
399
+ useEffect(() => {
400
+ if (lastVitals.current && sameVitals(lastVitals.current, vitals)) {
401
+ return
402
+ }
403
+ lastVitals.current = vitals
404
+ onVitalsRef.current?.(vitals)
405
+ })
513
406
 
514
407
  const onControlsRef = useRef(onControls)
515
408
  onControlsRef.current = onControls
@@ -565,11 +458,19 @@ export function SessionPanel({
565
458
  return
566
459
  }
567
460
  }
568
- setCaughtUp(true)
461
+ dismissCatchUp()
569
462
  repinTranscript.current?.()
570
463
  send(text, attachmentIds)
571
464
  }
572
465
 
466
+ const menuShows: Record<MenuPanel, boolean> = {
467
+ context: capabilities.contextUsage,
468
+ usage: capabilities.rateLimits,
469
+ info: true,
470
+ mcp: capabilities.mcpStatus,
471
+ skills: capabilities.skillsList,
472
+ files: hostFiles.available,
473
+ }
573
474
  const actionsMenu = (
574
475
  <Menu>
575
476
  <MenuTrigger
@@ -580,39 +481,17 @@ export function SessionPanel({
580
481
  }
581
482
  />
582
483
  <MenuContent>
583
- {capabilities.contextUsage ? (
584
- <MenuItem onClick={() => openPanel('context')}>
585
- <ChartPie className="size-3.5 text-fg-3" /> Context
586
- </MenuItem>
587
- ) : null}
588
- {capabilities.rateLimits ? (
589
- <MenuItem onClick={() => openPanel('usage')}>
590
- <Gauge className="size-3.5 text-fg-3" /> Usage
591
- </MenuItem>
592
- ) : null}
593
- <MenuItem onClick={() => openPanel('info')}>
594
- <Info className="size-3.5 text-fg-3" /> Session info
595
- </MenuItem>
596
- {capabilities.mcpStatus ? (
597
- <MenuItem onClick={() => openPanel('mcp')}>
598
- <Plug className="size-3.5 text-fg-3" /> MCP servers
484
+ {PANEL_ITEMS.filter((item) => menuShows[item.panel]).map(({ panel: target, label, Icon }) => (
485
+ <MenuItem key={target} onClick={() => openPanel(target)}>
486
+ <Icon className="size-3.5 text-fg-3" /> {label}
599
487
  </MenuItem>
600
- ) : null}
601
- {capabilities.skillsList ? (
602
- <MenuItem onClick={() => openPanel('skills')}>
603
- <Sparkles className="size-3.5 text-fg-3" /> Skills
604
- </MenuItem>
605
- ) : null}
606
- {hostFiles.available ? (
607
- <MenuItem onClick={() => openPanel('files')}>
608
- <FolderTree className="size-3.5 text-fg-3" /> Project files
609
- </MenuItem>
610
- ) : null}
488
+ ))}
611
489
  </MenuContent>
612
490
  </Menu>
613
491
  )
614
492
 
615
493
  const menu = external ? null : actionsMenu
494
+ const dialog = (target: Panel) => ({ open: panel === target, onOpenChange: (next: boolean) => setPanel(next ? target : undefined) })
616
495
  const headerTakesActions = typeof header === 'function'
617
496
 
618
497
  const sessionControls = (
@@ -649,8 +528,6 @@ export function SessionPanel({
649
528
  onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
650
529
  onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
651
530
  onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
652
- tasks={tasks}
653
- onOpenTasks={external && !onOpenPanel ? undefined : () => openPanel('tasks')}
654
531
  actions={headerTakesActions ? undefined : menu}
655
532
  />
656
533
  )
@@ -699,316 +576,190 @@ export function SessionPanel({
699
576
  }
700
577
 
701
578
  return (
702
- <TranscriptVariantProvider value={transcriptVariant}>
703
- <TranscriptDensityProvider value={transcriptDensity}>
704
- <FileLinkProvider value={fileLinks}>
705
- <ToolResultFetchProvider value={loadFullResult}>
706
- <ShellActionsProvider value={shellActions}>
707
- <ToolTitleProvider value={state.toolTitles}>
708
- <ToolResultImageProvider value={resultImages}>
709
- <div
710
- ref={panelRef}
711
- data-slot="session-panel"
712
- data-agent-font={transcriptFont}
713
- onClick={handleClick}
714
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
715
- style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
716
- >
717
- {headerTakesActions ? header({ actions: menu }) : header}
718
- {statusPlacement === 'top' ? statusBar : null}
719
- {protocolMismatch !== undefined ? (
720
- <Notice level="warning">
721
- Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
722
- </Notice>
723
- ) : null}
724
- {protocolError ? (
725
- <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
726
- {protocolError}
727
- </Notice>
728
- ) : null}
729
- {framedShellId !== undefined ? (
730
- <ShellStrip
731
- shell={framedShell}
732
- label={framedShellLabel}
733
- onBack={leaveShell}
734
- onKill={() => void killShell(framedShellId)}
735
- terminal={terminal}
736
- fontSize={effectiveTermFontSize}
737
- lineHeight={effectiveTermLineHeight}
738
- />
739
- ) : null}
740
- {framedShellId === undefined && subagentId !== undefined ? (
741
- <SubagentStrip
742
- task={subagentTask}
743
- items={subagentFrameItems}
744
- label={subagentFallbackLabel}
745
- onBack={leaveSubagent}
746
- terminal={terminal}
747
- fontSize={effectiveTermFontSize}
748
- lineHeight={effectiveTermLineHeight}
749
- />
750
- ) : null}
751
- {framedShellId !== undefined ? (
752
- <ShellTerminal key={framedShellId} handle={handle} shellId={framedShellId} fontSize={effectiveTermFontSize} />
753
- ) : (
754
- <BookmarkProvider value={bookmarkHandle}>
755
- <Transcript
756
- key={subagentId ?? 'session'}
757
- state={state}
758
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
759
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
760
- canBrowseFiles={hostFiles.available}
761
- sessionNames={peers.names}
762
- hostImage={hostImage}
763
- variant={transcriptVariant}
764
- density={transcriptDensity}
765
- fontSize={effectiveTermFontSize}
766
- lineHeight={effectiveTermLineHeight}
767
- affordances={affordances}
768
- stickyPrompt={stickyPrompt}
769
- scrubber={scrubber}
770
- bookmarks={bookmarks}
771
- replaying={replaying}
772
- catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
773
- reveal={taskReveal ?? frameReturnReveal ?? reveal}
774
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
775
- onOpenSubagent={enterSubagent}
776
- emptyState={emptyState}
777
- jumpToRecapRef={jumpToRecap}
778
- repinRef={repinTranscript}
779
- />
780
- </BookmarkProvider>
781
- )}
782
- {catchUp && newCount > 0 && !replaying && subagentId === undefined && framedShellId === undefined ? (
783
- <div className="px-3 pb-1">
784
- <div
785
- data-slot="catch-up"
786
- className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
787
- >
788
- <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
789
- ※
790
- </span>
791
- <span className="min-w-0 flex-1 truncate">
792
- {newCount} new {newCount === 1 ? 'row' : 'rows'}
793
- {catchUp.since !== undefined ? ` since you were last here` : ''}
794
- </span>
795
- <button
796
- type="button"
797
- onClick={() => jumpToRecap.current?.()}
798
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
799
- >
800
- jump
801
- </button>
802
- <button
803
- type="button"
804
- onClick={() => setCaughtUp(true)}
805
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
806
- >
807
- dismiss
808
- </button>
809
- </div>
810
- </div>
811
- ) : null}
812
- {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
813
- <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
814
- <PromptSurface
815
- terminal={terminal}
816
- metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
817
- affordances={affordances}
818
- >
819
- {state.pendingApprovals.map((request) => {
820
- // Before the variant split: a host's entry is the renderer for its tool in
821
- // both themes, and it overrides the built-in entries rather than racing them.
822
- const HostPrompt = approvalPrompts?.[request.toolName]
823
- if (HostPrompt) {
824
- return <HostPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
825
- }
826
- const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
827
- if (terminal) {
828
- return isQuestion ? (
829
- <TerminalQuestionPrompt
830
- key={request.id}
831
- request={request}
832
- onAnswer={approve}
833
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
834
- />
835
- ) : (
836
- <TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
837
- )
838
- }
839
- return isQuestion ? (
840
- <QuestionPrompt
841
- key={request.id}
842
- request={request}
843
- onAnswer={approve}
844
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
845
- />
846
- ) : (
847
- <PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
848
- )
849
- })}
850
- </PromptSurface>
851
- </div>
852
- ) : null}
853
- {readOnly || subagentId !== undefined || framedShellId !== undefined ? null : (
854
- <>
855
- <Composer
856
- ref={composerRef}
857
- onSend={handleSend}
858
- onInterrupt={interrupt}
859
- busy={busy}
860
- disabled={ended || !sessionId}
861
- commands={capabilities.slashCommands ? state.commands : undefined}
862
- skills={capabilities.skillsList ? state.skills : undefined}
863
- clientCommands={clientCommands}
864
- attachments={attachments}
865
- draft={draft}
866
- onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
867
- peers={peers.peers}
868
- onShellCommand={shell ? runShell : undefined}
869
- layout={controlsExternal ? 'inline' : 'stacked'}
870
- toolbar={controlsExternal ? undefined : sessionControls}
871
- fontSize={effectiveTermFontSize}
872
- lineHeight={effectiveTermLineHeight}
873
- affordances={affordances}
874
- />
875
- </>
876
- )}
877
- {statusPlacement === 'bottom' ? statusBar : null}
878
-
879
- {!external ? (
880
- <>
881
- <SessionInfoDialog
882
- state={state}
883
- client={client}
884
- sessionId={sessionId}
885
- open={panel === 'info'}
886
- onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
887
- />
888
- <ContextDialog
889
- usage={state.contextUsage}
890
- engine={state.engine ?? 'claude'}
891
- open={panel === 'context'}
892
- onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
893
- />
894
- <UsageDialog
895
- rateLimits={windows}
896
- subscriptionType={state.subscriptionType}
897
- engine={state.engine ?? 'claude'}
898
- totalCostUsd={state.totalCostUsd}
899
- costUsd={state.costUsd}
900
- usageByModel={state.usageByModel}
901
- pricing={pricing}
902
- spend={profileSpend}
903
- updatedAt={usageUpdatedAt}
904
- open={panel === 'usage'}
905
- onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
906
- />
907
- <McpDialog
908
- client={client}
909
- sessionId={sessionId}
910
- canManageServers={capabilities.mcpServerActions}
911
- open={panel === 'mcp'}
912
- onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
913
- />
914
- <TasksDialog
915
- tasks={tasks}
916
- showCompleted={showCompletedTasks}
917
- onShowCompletedChange={setShowCompletedTasks}
918
- onSelectTask={(task) => {
919
- if (task.toolUseId) {
920
- setTaskReveal({ toolUseId: task.toolUseId, nonce: Date.now() })
921
- }
922
- }}
923
- open={panel === 'tasks'}
924
- onOpenChange={(next) => setPanel(next ? 'tasks' : undefined)}
925
- />
926
- <SkillsDialog
927
- skills={state.skills}
928
- open={panel === 'skills'}
929
- onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
930
- onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
931
- />
932
- <HostFilesDialog
933
- client={client}
934
- cwd={state.cwd}
935
- open={panel === 'files'}
936
- onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
937
- />
938
- </>
939
- ) : null}
940
- </div>
941
- </ToolResultImageProvider>
942
- </ToolTitleProvider>
943
- </ShellActionsProvider>
944
- </ToolResultFetchProvider>
945
- </FileLinkProvider>
946
- </TranscriptDensityProvider>
947
- </TranscriptVariantProvider>
579
+ <SessionPanelProviders
580
+ variant={transcriptVariant}
581
+ fileLinks={fileLinks}
582
+ loadFullResult={loadFullResult}
583
+ shellActions={shellActions}
584
+ toolTitles={state.toolTitles}
585
+ resultImages={resultImages}
586
+ >
587
+ <div
588
+ ref={panelRef}
589
+ data-slot="session-panel"
590
+ data-agent-font={transcriptFont}
591
+ onClick={handleClick}
592
+ className={cn('relative flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
593
+ style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
594
+ >
595
+ <ImageViewerProvider>
596
+ {headerTakesActions ? header({ actions: menu }) : header}
597
+ {statusPlacement === 'top' ? statusBar : null}
598
+ {protocolMismatch !== undefined ? (
599
+ <Notice level="warning">
600
+ Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
601
+ </Notice>
602
+ ) : null}
603
+ {protocolError ? (
604
+ <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
605
+ {protocolError}
606
+ </Notice>
607
+ ) : null}
608
+ {framedShellId !== undefined ? (
609
+ <ShellStrip
610
+ shell={framedShell}
611
+ label={framedShellLabel}
612
+ onBack={leaveShell}
613
+ onKill={() => void killShell(framedShellId)}
614
+ onAgentWrite={agentWrite ? (enabled) => void agentWrite(framedShellId, enabled) : undefined}
615
+ terminal={terminal}
616
+ {...cell}
617
+ />
618
+ ) : null}
619
+ {framedShellId === undefined && subagentId !== undefined ? (
620
+ <SubagentStrip
621
+ task={subagentTask}
622
+ items={subagentFrameItems}
623
+ label={subagentFallbackLabel}
624
+ onBack={leaveSubagent}
625
+ terminal={terminal}
626
+ {...cell}
627
+ />
628
+ ) : null}
629
+ {framedShellId !== undefined ? (
630
+ <ShellTerminal key={framedShellId} handle={handle} shellId={framedShellId} fontSize={cell.fontSize} />
631
+ ) : (
632
+ <BookmarkProvider value={bookmarkHandle}>
633
+ <Transcript
634
+ key={subagentId ?? 'session'}
635
+ state={state}
636
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
637
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
638
+ canBrowseFiles={hostFiles.available}
639
+ sessionNames={peers.names}
640
+ hostImage={hostImage}
641
+ variant={transcriptVariant}
642
+ {...cell}
643
+ affordances={affordances}
644
+ stickyPrompt={stickyPrompt}
645
+ scrubber={scrubber}
646
+ bookmarks={bookmarks}
647
+ replaying={replaying}
648
+ catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
649
+ reveal={frameReturnReveal ?? reveal}
650
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
651
+ onOpenSubagent={enterSubagent}
652
+ emptyState={emptyState}
653
+ jumpToRecapRef={jumpToRecap}
654
+ repinRef={repinTranscript}
655
+ />
656
+ </BookmarkProvider>
657
+ )}
658
+ {catchUp && newCount > 0 && !replaying && subagentId === undefined && framedShellId === undefined ? (
659
+ <CatchUpBanner
660
+ count={newCount}
661
+ since={catchUp.since}
662
+ terminal={terminal}
663
+ onJump={() => jumpToRecap.current?.()}
664
+ onDismiss={dismissCatchUp}
665
+ />
666
+ ) : null}
667
+ {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
668
+ <ApprovalPrompts
669
+ requests={state.pendingApprovals}
670
+ terminal={terminal}
671
+ {...cell}
672
+ affordances={affordances}
673
+ hostPrompts={approvalPrompts}
674
+ onApprove={approve}
675
+ onDeny={deny}
676
+ />
677
+ ) : null}
678
+ {readOnly || subagentId !== undefined || framedShellId !== undefined ? null : (
679
+ <>
680
+ <Composer
681
+ ref={composerRef}
682
+ onSend={handleSend}
683
+ onInterrupt={interrupt}
684
+ busy={busy}
685
+ disabled={ended || !sessionId}
686
+ commands={capabilities.slashCommands ? state.commands : undefined}
687
+ skills={capabilities.skillsList ? state.skills : undefined}
688
+ clientCommands={clientCommands}
689
+ attachments={attachments}
690
+ draft={draft}
691
+ onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
692
+ peers={peers.peers}
693
+ onShellCommand={shell ? runShell : undefined}
694
+ layout={controlsExternal ? 'inline' : 'stacked'}
695
+ toolbar={controlsExternal ? undefined : sessionControls}
696
+ {...cell}
697
+ affordances={affordances}
698
+ />
699
+ </>
700
+ )}
701
+ {statusPlacement === 'bottom' ? statusBar : null}
702
+
703
+ {!external ? (
704
+ <>
705
+ <SessionInfoDialog state={state} client={client} sessionId={sessionId} {...dialog('info')} />
706
+ <ContextDialog usage={state.contextUsage} engine={state.engine ?? 'claude'} {...dialog('context')} />
707
+ <UsageDialog
708
+ rateLimits={windows}
709
+ subscriptionType={state.subscriptionType}
710
+ engine={state.engine ?? 'claude'}
711
+ totalCostUsd={state.totalCostUsd}
712
+ costUsd={state.costUsd}
713
+ usageByModel={state.usageByModel}
714
+ pricing={pricing}
715
+ spend={profileSpend}
716
+ updatedAt={usageUpdatedAt}
717
+ {...dialog('usage')}
718
+ />
719
+ <McpDialog client={client} sessionId={sessionId} canManageServers={capabilities.mcpServerActions} {...dialog('mcp')} />
720
+ <SkillsDialog
721
+ skills={state.skills}
722
+ {...dialog('skills')}
723
+ onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
724
+ />
725
+ <HostFilesDialog client={client} cwd={state.cwd} {...dialog('files')} />
726
+ </>
727
+ ) : null}
728
+ </ImageViewerProvider>
729
+ </div>
730
+ </SessionPanelProviders>
948
731
  )
949
732
  }
950
733
 
951
- function useHostImage(
952
- client: WorkerDeckClient,
953
- sessionId: string | undefined,
954
- producedFiles: Record<string, ProducedFileRef> | undefined,
955
- ): (path: string) => Promise<string | undefined> {
956
- const cache = useRef(new Map<string, Promise<string | undefined>>())
957
- const objectUrls = useRef<string[]>([])
958
- useEffect(
959
- () => () => {
960
- for (const url of objectUrls.current) {
961
- URL.revokeObjectURL(url)
962
- }
963
- objectUrls.current = []
964
- },
965
- [],
966
- )
967
- return useCallback(
968
- (path: string) => {
969
- const produced = producedFiles?.[path]
970
- const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
971
- const hit = cache.current.get(key)
972
- if (hit) {
973
- return hit
974
- }
975
- const pending =
976
- produced && sessionId
977
- ? client
978
- .readProducedFile(sessionId, produced.fileId)
979
- .then((blob) => {
980
- if (blob.size === 0) {
981
- return undefined
982
- }
983
- const url = URL.createObjectURL(blob)
984
- objectUrls.current.push(url)
985
- return url
986
- })
987
- .catch(() => undefined)
988
- : client
989
- .readHostFile(path)
990
- .then((file) => {
991
- if (file.encoding !== 'base64') {
992
- return undefined
993
- }
994
- const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
995
- const mediaType = IMAGE_MEDIA_TYPES[extension]
996
- return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
997
- })
998
- .catch(() => undefined)
999
- cache.current.set(key, pending)
1000
- return pending
1001
- },
1002
- [client, sessionId, producedFiles],
734
+ type CatchUpBannerProps = { count: number; since: number | undefined; terminal: boolean; onJump: () => void; onDismiss: () => void }
735
+
736
+ function CatchUpBanner({ count, since, terminal, onJump, onDismiss }: CatchUpBannerProps) {
737
+ return (
738
+ <div className="px-3 pb-1">
739
+ <div
740
+ data-slot="catch-up"
741
+ className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
742
+ >
743
+ <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
744
+ ※
745
+ </span>
746
+ <span className="min-w-0 flex-1 truncate">
747
+ {count} new {count === 1 ? 'row' : 'rows'}
748
+ {since !== undefined ? ` since you were last here` : ''}
749
+ </span>
750
+ <button type="button" onClick={onJump} className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline">
751
+ jump
752
+ </button>
753
+ <button type="button" onClick={onDismiss} className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline">
754
+ dismiss
755
+ </button>
756
+ </div>
757
+ </div>
1003
758
  )
1004
759
  }
1005
760
 
1006
- const IMAGE_MEDIA_TYPES: Record<string, string> = {
1007
- png: 'image/png',
1008
- jpg: 'image/jpeg',
1009
- jpeg: 'image/jpeg',
1010
- gif: 'image/gif',
1011
- webp: 'image/webp',
761
+ function sameVitals(a: SessionVitals, b: SessionVitals): boolean {
762
+ return (Object.keys(b) as (keyof SessionVitals)[]).every((key) => Object.is(a[key], b[key]))
1012
763
  }
1013
764
 
1014
765
  function Notice({ level, onDismiss, children }: { level: 'warning' | 'error'; onDismiss?: () => void; children: ReactNode }) {