@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
@@ -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,90 +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'
72
+ import { useToolResultImages } from './tool-result-image.tsx'
81
73
  import { ImageViewerProvider } from './image-viewer.tsx'
82
- import { TranscriptVariantProvider, type TranscriptFont, type TranscriptVariant } from './transcript-variant.tsx'
74
+ import type { TranscriptFont, TranscriptVariant } from './transcript-variant.tsx'
83
75
  import { UsageDialog } from './UsageDialog.tsx'
84
76
 
85
- function PromptSurface({
86
- terminal,
87
- metrics,
88
- affordances,
89
- children,
90
- }: {
91
- terminal: boolean
92
- metrics?: TerminalMetrics
93
- affordances?: TerminalAffordances | boolean
94
- children: ReactNode
95
- }) {
96
- if (!terminal) {
97
- return <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2">{children}</div>
98
- }
99
- return (
100
- <TerminalSurface
101
- fontSize={metrics?.fontSize}
102
- lineHeight={metrics?.lineHeight}
103
- affordances={affordances}
104
- bleed="1ch"
105
- className="term-transcript"
106
- >
107
- {children}
108
- </TerminalSurface>
109
- )
110
- }
111
-
112
77
  export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
113
78
 
114
- // Deliberately the built-in prompts' own callback shapes, so `PermissionPrompt` and
115
- // `TerminalPermissionPrompt` stay drop-in fallbacks for an input a host declines to draw.
116
- export interface ApprovalPromptProps {
117
- request: PermissionRequest
118
- onApprove: (requestId: string, updatedInput?: Record<string, unknown>) => void
119
- onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
120
- }
79
+ export type { ApprovalPromptProps }
121
80
 
122
81
  export interface SessionPanelProps {
123
82
  client: WorkerDeckClient
@@ -192,6 +151,16 @@ const INTERACTIVE = [
192
151
 
193
152
  export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills' | 'tasks'
194
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
+ ]
195
164
 
196
165
  export type SessionVitals = {
197
166
  status: TranscriptState['status']
@@ -257,8 +226,10 @@ export function SessionPanel({
257
226
  fontSize,
258
227
  className,
259
228
  }: SessionPanelProps) {
260
- const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
261
- 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
+ }
262
233
 
263
234
  const external = panelSurface === 'external'
264
235
  const statusExternal = statusSurface === 'external'
@@ -266,8 +237,6 @@ export function SessionPanel({
266
237
  const controlsExternal = controlsSurface === 'external' || controlsInStatus
267
238
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
268
239
  const [panel, setPanel] = useState<Panel | undefined>()
269
- const [showCompletedTasks, setShowCompletedTasks] = useState(false)
270
- const [taskReveal, setTaskReveal] = useState<{ toolUseId: string; nonce: number } | undefined>()
271
240
  const {
272
241
  state,
273
242
  connection,
@@ -326,12 +295,7 @@ export function SessionPanel({
326
295
  return returnReveal ?? shellReturnReveal
327
296
  }, [returnReveal, shellReturnReveal])
328
297
 
329
- const [caughtUp, setCaughtUp] = useState(false)
330
- useEffect(() => {
331
- setCaughtUp(false)
332
- }, [sessionId])
333
- const [catchUpMark] = useState(unseen)
334
- const catchUp = caughtUp ? undefined : catchUpMark
298
+ const { mark: catchUp, dismiss: dismissCatchUp } = useCatchUp(sessionId, unseen)
335
299
  const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
336
300
 
337
301
  const openPanel = useCallback(
@@ -386,66 +350,11 @@ export function SessionPanel({
386
350
  [state.checklist, subagents, state.session?.subagents],
387
351
  )
388
352
 
389
- const clientCommands = useMemo((): ClientCommand[] => {
390
- const modes = capabilities.permissionModes
391
- const built: ClientCommand[] = []
392
- if (models.length > 0) {
393
- built.push({
394
- name: 'model',
395
- description: 'Switch the model for this session',
396
- argumentHint: '<model>',
397
- requiresArgs: true,
398
- run: (args) => {
399
- const wanted = args.split(/\s+/)[0]
400
- if (!wanted) {
401
- return false
402
- }
403
- setModel(wanted)
404
- return true
405
- },
406
- })
407
- }
408
- if (modes.length > 1) {
409
- built.push({
410
- name: 'permissions',
411
- description: `Set the permission mode (${modes.join(', ')})`,
412
- argumentHint: '<mode>',
413
- requiresArgs: true,
414
- run: (args) => {
415
- const wanted = args.split(/\s+/)[0] as PermissionMode
416
- if (!modes.includes(wanted)) {
417
- return false
418
- }
419
- setPermissionMode(wanted)
420
- return true
421
- },
422
- })
423
- }
424
- if (capabilities.clearContext) {
425
- built.push({
426
- name: 'clear',
427
- description: 'Clear the conversation - the session keeps running and the old one stays resumable',
428
- run: () => {
429
- clearContext()
430
- return true
431
- },
432
- })
433
- }
434
- if (capabilities.mcpStatus) {
435
- built.push({ name: 'mcp', description: 'MCP servers and their status', run: () => (openPanel('mcp'), true) })
436
- }
437
- if (capabilities.contextUsage) {
438
- built.push({ name: 'context', description: 'Context window usage', run: () => (openPanel('context'), true) })
439
- }
440
- built.push({ name: 'status', description: 'Session details', run: () => (openPanel('info'), true) })
441
- if (capabilities.rateLimits) {
442
- built.push({ name: 'usage', description: 'Rate limits and spend', run: () => (openPanel('usage'), true) })
443
- }
444
- if (capabilities.skillsList) {
445
- built.push({ name: 'skills', description: 'Browse the skills this session can use', run: () => (openPanel('skills'), true) })
446
- }
447
- return built
448
- }, [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
+ )
449
358
 
450
359
  const composerCommands = useMemo(
451
360
  () =>
@@ -466,45 +375,34 @@ export function SessionPanel({
466
375
  () => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
467
376
  [capabilities.permissionModes, state.session?.canBypassPermissions],
468
377
  )
469
- useEffect(() => {
470
- onVitalsRef.current?.({
471
- status: state.status,
472
- connection,
473
- engine: state.engine,
474
- capabilities: state.capabilities,
475
- model: vitalsModel,
476
- models,
477
- permissionMode: state.permissionMode,
478
- permissionModes,
479
- skills: state.skills,
480
- composerCommands,
481
- cwd: state.cwd,
482
- contextUsage: state.contextUsage,
483
- tasks,
484
- rateLimits,
485
- rateLimitsUpdatedAt: usageUpdatedAt,
486
- itemCount: state.items.length,
487
- totalCostUsd: state.totalCostUsd,
488
- })
489
- }, [
490
- state.status,
378
+ const vitals: SessionVitals = {
379
+ status: state.status,
491
380
  connection,
492
- state.engine,
493
- state.capabilities,
494
- vitalsModel,
381
+ engine: state.engine,
382
+ capabilities: state.capabilities,
383
+ model: vitalsModel,
495
384
  models,
496
- state.permissionMode,
385
+ permissionMode: state.permissionMode,
497
386
  permissionModes,
498
- state.skills,
387
+ skills: state.skills,
499
388
  composerCommands,
500
- state.cwd,
501
- state.contextUsage,
389
+ cwd: state.cwd,
390
+ contextUsage: state.contextUsage,
502
391
  tasks,
503
392
  rateLimits,
504
- usageUpdatedAt,
505
- state.items.length,
506
- state.totalCostUsd,
507
- ])
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
+ })
508
406
 
509
407
  const onControlsRef = useRef(onControls)
510
408
  onControlsRef.current = onControls
@@ -560,11 +458,19 @@ export function SessionPanel({
560
458
  return
561
459
  }
562
460
  }
563
- setCaughtUp(true)
461
+ dismissCatchUp()
564
462
  repinTranscript.current?.()
565
463
  send(text, attachmentIds)
566
464
  }
567
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
+ }
568
474
  const actionsMenu = (
569
475
  <Menu>
570
476
  <MenuTrigger
@@ -575,39 +481,17 @@ export function SessionPanel({
575
481
  }
576
482
  />
577
483
  <MenuContent>
578
- {capabilities.contextUsage ? (
579
- <MenuItem onClick={() => openPanel('context')}>
580
- <ChartPie className="size-3.5 text-fg-3" /> Context
581
- </MenuItem>
582
- ) : null}
583
- {capabilities.rateLimits ? (
584
- <MenuItem onClick={() => openPanel('usage')}>
585
- <Gauge className="size-3.5 text-fg-3" /> Usage
586
- </MenuItem>
587
- ) : null}
588
- <MenuItem onClick={() => openPanel('info')}>
589
- <Info className="size-3.5 text-fg-3" /> Session info
590
- </MenuItem>
591
- {capabilities.mcpStatus ? (
592
- <MenuItem onClick={() => openPanel('mcp')}>
593
- <Plug className="size-3.5 text-fg-3" /> MCP servers
594
- </MenuItem>
595
- ) : null}
596
- {capabilities.skillsList ? (
597
- <MenuItem onClick={() => openPanel('skills')}>
598
- <Sparkles className="size-3.5 text-fg-3" /> Skills
599
- </MenuItem>
600
- ) : null}
601
- {hostFiles.available ? (
602
- <MenuItem onClick={() => openPanel('files')}>
603
- <FolderTree className="size-3.5 text-fg-3" /> Project files
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}
604
487
  </MenuItem>
605
- ) : null}
488
+ ))}
606
489
  </MenuContent>
607
490
  </Menu>
608
491
  )
609
492
 
610
493
  const menu = external ? null : actionsMenu
494
+ const dialog = (target: Panel) => ({ open: panel === target, onOpenChange: (next: boolean) => setPanel(next ? target : undefined) })
611
495
  const headerTakesActions = typeof header === 'function'
612
496
 
613
497
  const sessionControls = (
@@ -644,8 +528,6 @@ export function SessionPanel({
644
528
  onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
645
529
  onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
646
530
  onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
647
- tasks={tasks}
648
- onOpenTasks={external && !onOpenPanel ? undefined : () => openPanel('tasks')}
649
531
  actions={headerTakesActions ? undefined : menu}
650
532
  />
651
533
  )
@@ -694,316 +576,190 @@ export function SessionPanel({
694
576
  }
695
577
 
696
578
  return (
697
- <TranscriptVariantProvider value={transcriptVariant}>
698
- <FileLinkProvider value={fileLinks}>
699
- <ToolResultFetchProvider value={loadFullResult}>
700
- <ShellActionsProvider value={shellActions}>
701
- <ToolTitleProvider value={state.toolTitles}>
702
- <ToolResultImageProvider value={resultImages}>
703
- <div
704
- ref={panelRef}
705
- data-slot="session-panel"
706
- data-agent-font={transcriptFont}
707
- onClick={handleClick}
708
- className={cn('relative flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
709
- style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
710
- >
711
- <ImageViewerProvider>
712
- {headerTakesActions ? header({ actions: menu }) : header}
713
- {statusPlacement === 'top' ? statusBar : null}
714
- {protocolMismatch !== undefined ? (
715
- <Notice level="warning">
716
- Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
717
- </Notice>
718
- ) : null}
719
- {protocolError ? (
720
- <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
721
- {protocolError}
722
- </Notice>
723
- ) : null}
724
- {framedShellId !== undefined ? (
725
- <ShellStrip
726
- shell={framedShell}
727
- label={framedShellLabel}
728
- onBack={leaveShell}
729
- onKill={() => void killShell(framedShellId)}
730
- onAgentWrite={agentWrite ? (enabled) => void agentWrite(framedShellId, enabled) : undefined}
731
- terminal={terminal}
732
- fontSize={effectiveTermFontSize}
733
- lineHeight={effectiveTermLineHeight}
734
- />
735
- ) : null}
736
- {framedShellId === undefined && subagentId !== undefined ? (
737
- <SubagentStrip
738
- task={subagentTask}
739
- items={subagentFrameItems}
740
- label={subagentFallbackLabel}
741
- onBack={leaveSubagent}
742
- terminal={terminal}
743
- fontSize={effectiveTermFontSize}
744
- lineHeight={effectiveTermLineHeight}
745
- />
746
- ) : null}
747
- {framedShellId !== undefined ? (
748
- <ShellTerminal key={framedShellId} handle={handle} shellId={framedShellId} fontSize={effectiveTermFontSize} />
749
- ) : (
750
- <BookmarkProvider value={bookmarkHandle}>
751
- <Transcript
752
- key={subagentId ?? 'session'}
753
- state={state}
754
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
755
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
756
- canBrowseFiles={hostFiles.available}
757
- sessionNames={peers.names}
758
- hostImage={hostImage}
759
- variant={transcriptVariant}
760
- fontSize={effectiveTermFontSize}
761
- lineHeight={effectiveTermLineHeight}
762
- affordances={affordances}
763
- stickyPrompt={stickyPrompt}
764
- scrubber={scrubber}
765
- bookmarks={bookmarks}
766
- replaying={replaying}
767
- catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
768
- reveal={taskReveal ?? frameReturnReveal ?? reveal}
769
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
770
- onOpenSubagent={enterSubagent}
771
- emptyState={emptyState}
772
- jumpToRecapRef={jumpToRecap}
773
- repinRef={repinTranscript}
774
- />
775
- </BookmarkProvider>
776
- )}
777
- {catchUp && newCount > 0 && !replaying && subagentId === undefined && framedShellId === undefined ? (
778
- <div className="px-3 pb-1">
779
- <div
780
- data-slot="catch-up"
781
- className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
782
- >
783
- <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
784
- ※
785
- </span>
786
- <span className="min-w-0 flex-1 truncate">
787
- {newCount} new {newCount === 1 ? 'row' : 'rows'}
788
- {catchUp.since !== undefined ? ` since you were last here` : ''}
789
- </span>
790
- <button
791
- type="button"
792
- onClick={() => jumpToRecap.current?.()}
793
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
794
- >
795
- jump
796
- </button>
797
- <button
798
- type="button"
799
- onClick={() => setCaughtUp(true)}
800
- className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
801
- >
802
- dismiss
803
- </button>
804
- </div>
805
- </div>
806
- ) : null}
807
- {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
808
- <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
809
- <PromptSurface
810
- terminal={terminal}
811
- metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
812
- affordances={affordances}
813
- >
814
- {state.pendingApprovals.map((request) => {
815
- // Before the variant split: a host's entry is the renderer for its tool in
816
- // both themes, and it overrides the built-in entries rather than racing them.
817
- const HostPrompt = approvalPrompts?.[request.toolName]
818
- if (HostPrompt) {
819
- return <HostPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
820
- }
821
- const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
822
- if (terminal) {
823
- return isQuestion ? (
824
- <TerminalQuestionPrompt
825
- key={request.id}
826
- request={request}
827
- onAnswer={approve}
828
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
829
- />
830
- ) : (
831
- <TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
832
- )
833
- }
834
- return isQuestion ? (
835
- <QuestionPrompt
836
- key={request.id}
837
- request={request}
838
- onAnswer={approve}
839
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
840
- />
841
- ) : (
842
- <PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
843
- )
844
- })}
845
- </PromptSurface>
846
- </div>
847
- ) : null}
848
- {readOnly || subagentId !== undefined || framedShellId !== undefined ? null : (
849
- <>
850
- <Composer
851
- ref={composerRef}
852
- onSend={handleSend}
853
- onInterrupt={interrupt}
854
- busy={busy}
855
- disabled={ended || !sessionId}
856
- commands={capabilities.slashCommands ? state.commands : undefined}
857
- skills={capabilities.skillsList ? state.skills : undefined}
858
- clientCommands={clientCommands}
859
- attachments={attachments}
860
- draft={draft}
861
- onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
862
- peers={peers.peers}
863
- onShellCommand={shell ? runShell : undefined}
864
- layout={controlsExternal ? 'inline' : 'stacked'}
865
- toolbar={controlsExternal ? undefined : sessionControls}
866
- fontSize={effectiveTermFontSize}
867
- lineHeight={effectiveTermLineHeight}
868
- affordances={affordances}
869
- />
870
- </>
871
- )}
872
- {statusPlacement === 'bottom' ? statusBar : null}
873
-
874
- {!external ? (
875
- <>
876
- <SessionInfoDialog
877
- state={state}
878
- client={client}
879
- sessionId={sessionId}
880
- open={panel === 'info'}
881
- onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
882
- />
883
- <ContextDialog
884
- usage={state.contextUsage}
885
- engine={state.engine ?? 'claude'}
886
- open={panel === 'context'}
887
- onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
888
- />
889
- <UsageDialog
890
- rateLimits={windows}
891
- subscriptionType={state.subscriptionType}
892
- engine={state.engine ?? 'claude'}
893
- totalCostUsd={state.totalCostUsd}
894
- costUsd={state.costUsd}
895
- usageByModel={state.usageByModel}
896
- pricing={pricing}
897
- spend={profileSpend}
898
- updatedAt={usageUpdatedAt}
899
- open={panel === 'usage'}
900
- onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
901
- />
902
- <McpDialog
903
- client={client}
904
- sessionId={sessionId}
905
- canManageServers={capabilities.mcpServerActions}
906
- open={panel === 'mcp'}
907
- onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
908
- />
909
- <TasksDialog
910
- tasks={tasks}
911
- showCompleted={showCompletedTasks}
912
- onShowCompletedChange={setShowCompletedTasks}
913
- onSelectTask={(task) => {
914
- if (task.toolUseId) {
915
- setTaskReveal({ toolUseId: task.toolUseId, nonce: Date.now() })
916
- }
917
- }}
918
- open={panel === 'tasks'}
919
- onOpenChange={(next) => setPanel(next ? 'tasks' : undefined)}
920
- />
921
- <SkillsDialog
922
- skills={state.skills}
923
- open={panel === 'skills'}
924
- onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
925
- onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
926
- />
927
- <HostFilesDialog
928
- client={client}
929
- cwd={state.cwd}
930
- open={panel === 'files'}
931
- onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
932
- />
933
- </>
934
- ) : null}
935
- </ImageViewerProvider>
936
- </div>
937
- </ToolResultImageProvider>
938
- </ToolTitleProvider>
939
- </ShellActionsProvider>
940
- </ToolResultFetchProvider>
941
- </FileLinkProvider>
942
- </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>
943
731
  )
944
732
  }
945
733
 
946
- function useHostImage(
947
- client: WorkerDeckClient,
948
- sessionId: string | undefined,
949
- producedFiles: Record<string, ProducedFileRef> | undefined,
950
- ): (path: string) => Promise<string | undefined> {
951
- const cache = useRef(new Map<string, Promise<string | undefined>>())
952
- const objectUrls = useRef<string[]>([])
953
- useEffect(
954
- () => () => {
955
- for (const url of objectUrls.current) {
956
- URL.revokeObjectURL(url)
957
- }
958
- objectUrls.current = []
959
- },
960
- [],
961
- )
962
- return useCallback(
963
- (path: string) => {
964
- const produced = producedFiles?.[path]
965
- const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
966
- const hit = cache.current.get(key)
967
- if (hit) {
968
- return hit
969
- }
970
- const pending =
971
- produced && sessionId
972
- ? client
973
- .readProducedFile(sessionId, produced.fileId)
974
- .then((blob) => {
975
- if (blob.size === 0) {
976
- return undefined
977
- }
978
- const url = URL.createObjectURL(blob)
979
- objectUrls.current.push(url)
980
- return url
981
- })
982
- .catch(() => undefined)
983
- : client
984
- .readHostFile(path)
985
- .then((file) => {
986
- if (file.encoding !== 'base64') {
987
- return undefined
988
- }
989
- const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
990
- const mediaType = IMAGE_MEDIA_TYPES[extension]
991
- return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
992
- })
993
- .catch(() => undefined)
994
- cache.current.set(key, pending)
995
- return pending
996
- },
997
- [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>
998
758
  )
999
759
  }
1000
760
 
1001
- const IMAGE_MEDIA_TYPES: Record<string, string> = {
1002
- png: 'image/png',
1003
- jpg: 'image/jpeg',
1004
- jpeg: 'image/jpeg',
1005
- gif: 'image/gif',
1006
- 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]))
1007
763
  }
1008
764
 
1009
765
  function Notice({ level, onDismiss, children }: { level: 'warning' | 'error'; onDismiss?: () => void; children: ReactNode }) {