@workerdeck/ui 0.22.0 → 0.23.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.
@@ -416,6 +416,62 @@ export interface SessionPanelProps {
416
416
  * "`>_` Tell the agent what to do." placeholder.
417
417
  */
418
418
  emptyState?: ReactNode
419
+ /**
420
+ * Called when a link in the transcript is clicked. The embedder decides what
421
+ * happens: navigate in-app, open a browser tab, show a confirmation, or
422
+ * suppress.
423
+ *
424
+ * Return `true` (or a truthy value) to indicate the click was handled — the
425
+ * default action (`window.open(href, '_blank')`) is suppressed. Return
426
+ * `false` / `undefined` / nothing to let the browser open the link normally.
427
+ *
428
+ * Absent means "browser default" — links open in a new tab as Streamdown's
429
+ * `target="_blank"` intends. VS Code's webview overrides this through its own
430
+ * native handler (the "allow once / add to allowlist" dialog) and does not
431
+ * need this prop.
432
+ *
433
+ * **Typical embedder patterns:**
434
+ * - Relative URLs → in-app navigation, no confirmation
435
+ * - External URLs → confirmation dialog, or open unconditionally
436
+ * - Suppress all links → `() => true`
437
+ */
438
+ onLinkClick?: (href: string) => boolean | void
439
+ /**
440
+ * Client-side tool handlers. Each key is a tool name the model can call; the
441
+ * handler receives the model's input and returns a result. The tool's
442
+ * **schema** must be registered server-side (via `tools` on
443
+ * `ProviderRunnerOptions`), but the handler runs here — right where the data
444
+ * the tool needs lives.
445
+ *
446
+ * Shorthand for `toolHost.clientTools`; when both are set, this wins for
447
+ * overlapping names.
448
+ *
449
+ * ```tsx
450
+ * <SessionPanel
451
+ * clientTools={{
452
+ * app_navigate: async (input) => {
453
+ * router.push((input as { path: string }).path)
454
+ * return { value: 'navigated' }
455
+ * },
456
+ * }}
457
+ * />
458
+ * ```
459
+ */
460
+ clientTools?: Record<string, import('@workerdeck/react').ClientToolHandler>
461
+ /**
462
+ * Base font size in **whole pixels**. Drives the overall scale of everything
463
+ * the panel draws — prompt, output, markdown, status bar — in both variants.
464
+ *
465
+ * Under the terminal theme it sets `--term-font-size` and derives
466
+ * `--term-line` at the CLI's own 13 : 18 ratio (unless {@link terminalMetrics}
467
+ * overrides those individually). Under cards it sets the panel root's
468
+ * `font-size`, which scales every `rem`/`em`-based token the type scale uses.
469
+ *
470
+ * Absent means "platform default": 13 px for the terminal theme, the
471
+ * inherited body size for cards. That is the right choice for a host that has
472
+ * no preference — the panel reads at the size the rest of the app does.
473
+ */
474
+ fontSize?: number
419
475
  className?: string
420
476
  }
421
477
 
@@ -529,10 +585,23 @@ export function SessionPanel({
529
585
  unseen,
530
586
  readOnly = false,
531
587
  toolHost,
588
+ clientTools,
532
589
  cacheTranscript,
533
590
  emptyState,
591
+ onLinkClick,
592
+ fontSize,
534
593
  className,
535
594
  }: SessionPanelProps) {
595
+ // ── Font size resolution ──────────────────────────────────────────────
596
+ // `fontSize` is the panel-wide knob; `terminalMetrics` is the terminal-
597
+ // specific override that predates it. The two compose: `fontSize` provides
598
+ // the default the terminal derives from (13 : 18 ratio), and individual
599
+ // terminalMetrics fields can still override each axis.
600
+ const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
601
+ const effectiveTermLineHeight =
602
+ terminalMetrics?.lineHeight ??
603
+ (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
604
+
536
605
  const external = panelSurface === 'external'
537
606
  const statusExternal = statusSurface === 'external'
538
607
  // Both non-internal surfaces take the pickers out of the composer, which is
@@ -714,7 +783,14 @@ export function SessionPanel({
714
783
  // SAME handle the panel attached with — the bridge asks the first attached
715
784
  // client. Free for Claude sessions: the guest loads lazily on the first call,
716
785
  // which for them never comes.
717
- useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
786
+ useToolCallHost(
787
+ handle,
788
+ toolHost === false
789
+ ? { enabled: false }
790
+ : clientTools
791
+ ? { ...toolHost, clientTools: { ...toolHost?.clientTools, ...clientTools } }
792
+ : toolHost,
793
+ )
718
794
  const terminal = transcriptVariant === 'terminal'
719
795
 
720
796
  // What the strip reads. The frame's items and its spawning call both come from
@@ -1012,6 +1088,31 @@ export function SessionPanel({
1012
1088
  />
1013
1089
  )
1014
1090
 
1091
+ // ── Link click handler ────────────────────────────────────────────────
1092
+ // When onLinkClick is provided, intercept <a> clicks on the panel root so
1093
+ // the embedder controls navigation. Capture phase so it fires before any
1094
+ // default handling. Only installed when the prop is present — a host that
1095
+ // does not provide it (VS Code, the dashboard) gets the browser / webview
1096
+ // default, which is the right thing in both cases.
1097
+ const panelRef = useRef<HTMLDivElement>(null)
1098
+ useEffect(() => {
1099
+ if (!onLinkClick) return
1100
+ const el = panelRef.current
1101
+ if (!el) return
1102
+ const handler = (e: MouseEvent) => {
1103
+ const anchor = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
1104
+ if (!anchor) return
1105
+ const href = anchor.getAttribute('href')
1106
+ if (!href) return
1107
+ if (onLinkClick(href)) {
1108
+ e.preventDefault()
1109
+ e.stopPropagation()
1110
+ }
1111
+ }
1112
+ el.addEventListener('click', handler, true)
1113
+ return () => el.removeEventListener('click', handler, true)
1114
+ }, [onLinkClick])
1115
+
1015
1116
  // Dead-space clicks land in the composer. Anything the user actually aimed at
1016
1117
  // — a control, a link, the end of a drag-selection — keeps its own meaning;
1017
1118
  // this only claims what was left over.
@@ -1036,13 +1137,15 @@ export function SessionPanel({
1036
1137
  <ToolResultFetchProvider value={loadFullResult}>
1037
1138
  <ToolResultImageProvider value={resultImages}>
1038
1139
  <div
1140
+ ref={panelRef}
1039
1141
  data-slot='session-panel'
1040
1142
  // The typeface is a cascade fact, not a React one — one attribute here,
1041
1143
  // and the `[data-agent-font]` rule in theme.css does the rest. Nothing
1042
1144
  // outside this subtree can pick it up.
1043
1145
  data-agent-font={transcriptFont}
1044
1146
  onClick={handleClick}
1045
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
1147
+ className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
1148
+ style={fontSize !== undefined ? { '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties : undefined}>
1046
1149
  {headerTakesActions ? header({ actions: menu }) : header}
1047
1150
  {statusPlacement === 'top' ? statusBar : null}
1048
1151
  {protocolMismatch !== undefined ? (
@@ -1067,8 +1170,8 @@ export function SessionPanel({
1067
1170
  label={subagentFallbackLabel}
1068
1171
  onBack={leaveSubagent}
1069
1172
  terminal={terminal}
1070
- fontSize={terminalMetrics?.fontSize}
1071
- lineHeight={terminalMetrics?.lineHeight}
1173
+ fontSize={effectiveTermFontSize}
1174
+ lineHeight={effectiveTermLineHeight}
1072
1175
  />
1073
1176
  ) : null}
1074
1177
  <Transcript
@@ -1084,8 +1187,8 @@ export function SessionPanel({
1084
1187
  hostImage={hostImage}
1085
1188
  variant={transcriptVariant}
1086
1189
  density={transcriptDensity}
1087
- fontSize={terminalMetrics?.fontSize}
1088
- lineHeight={terminalMetrics?.lineHeight}
1190
+ fontSize={effectiveTermFontSize}
1191
+ lineHeight={effectiveTermLineHeight}
1089
1192
  affordances={affordances}
1090
1193
  stickyPrompt={stickyPrompt}
1091
1194
  scrubber={scrubber}
@@ -1147,7 +1250,7 @@ export function SessionPanel({
1147
1250
  session is actually driven. */}
1148
1251
  {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
1149
1252
  <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
1150
- <PromptSurface terminal={terminal} metrics={terminalMetrics} affordances={affordances}>
1253
+ <PromptSurface terminal={terminal} metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }} affordances={affordances}>
1151
1254
  {state.pendingApprovals.map((request) => {
1152
1255
  const isQuestion =
1153
1256
  request.toolName === 'AskUserQuestion' &&
@@ -1215,8 +1318,8 @@ export function SessionPanel({
1215
1318
  }
1216
1319
  layout={controlsExternal ? 'inline' : 'stacked'}
1217
1320
  toolbar={controlsExternal ? undefined : sessionControls}
1218
- fontSize={terminalMetrics?.fontSize}
1219
- lineHeight={terminalMetrics?.lineHeight}
1321
+ fontSize={effectiveTermFontSize}
1322
+ lineHeight={effectiveTermLineHeight}
1220
1323
  affordances={affordances}
1221
1324
  />
1222
1325
  )}
@@ -53,6 +53,10 @@ export interface SessionWorkspaceProps {
53
53
  /** Which end of the panel the status bar sits at — see `SessionPanel`. */
54
54
  statusPlacement?: SessionPanelProps['statusPlacement']
55
55
  controlsSurface?: SessionPanelProps['controlsSurface']
56
+ /** Base font size in whole pixels — see `SessionPanel.fontSize`. */
57
+ fontSize?: SessionPanelProps['fontSize']
58
+ /** Link click handler — see `SessionPanel.onLinkClick`. */
59
+ onLinkClick?: SessionPanelProps['onLinkClick']
56
60
  unseen?: SessionPanelProps['unseen']
57
61
  /** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
58
62
  * {@link SessionPanel}; the file tree and editor are unaffected, since reading
@@ -117,6 +121,8 @@ export function SessionWorkspace({
117
121
  onSubagentChange,
118
122
  statusPlacement,
119
123
  controlsSurface,
124
+ fontSize,
125
+ onLinkClick,
120
126
  unseen,
121
127
  readOnly,
122
128
  onVitals,
@@ -323,6 +329,8 @@ export function SessionWorkspace({
323
329
  reveal={reveal}
324
330
  onSubagentChange={onSubagentChange}
325
331
  controlsSurface={controlsSurface}
332
+ fontSize={fontSize}
333
+ onLinkClick={onLinkClick}
326
334
  statusPlacement={statusPlacement}
327
335
  unseen={unseen}
328
336
  readOnly={readOnly}
@@ -262,10 +262,10 @@ export function StatusBar({
262
262
  </span>
263
263
  </Slot>
264
264
  ) : null}
265
- {controls}
265
+ {controls ? <span className='self-center'>{controls}</span> : null}
266
266
  <span className='flex-1' />
267
267
  <span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
268
- {actions}
268
+ {actions ? <span className='self-center'>{actions}</span> : null}
269
269
  </div>
270
270
  )
271
271
  }
@@ -485,6 +485,59 @@
485
485
  }
486
486
  }
487
487
 
488
+ /* ── Panel font size & Streamdown overrides ──────────────────────────────
489
+ *
490
+ * OUTSIDE `@layer base` so these beat Tailwind utility classes in the cascade
491
+ * (utilities live in a layer; unlayered rules always win over layered ones).
492
+ *
493
+ * `--wd-font-size` is set on the session panel root by the `fontSize` prop.
494
+ * When present it drives all text in the cards variant — body text, headings,
495
+ * code, tables — through one value. When absent the fallback `var(--text-body-sm)`
496
+ * resolves to the same `0.8125rem` the utility class used to set.
497
+ *
498
+ * Streamdown ships `rem`-based heading sizes (text-3xl … text-sm) that resolve
499
+ * against the document root and ignore the panel's local font-size. The `em`-based
500
+ * overrides below scale with `--wd-font-size`. The specificity confines this to
501
+ * cards-mode assistant output; the terminal theme renders headings as weight-only
502
+ * (no size change) through its own components and is unaffected.
503
+ *
504
+ * The heading scale mirrors Streamdown's hierarchy but at a tighter range — its
505
+ * 30 → 14 px span (h1 → h6) sits inside a 13px body, so the ratio is condensed:
506
+ * h1 at 1.5em, descending to h6 at 1em.
507
+ */
508
+ [data-slot='message-content'] {
509
+ font-size: var(--wd-font-size, var(--text-body-sm));
510
+ line-height: 1.6;
511
+ }
512
+ [data-slot='message-content'] h1 { font-size: 1.5em; line-height: 1.35; margin-top: 1.5em; }
513
+ [data-slot='message-content'] h2 { font-size: 1.3em; line-height: 1.35; margin-top: 1.5em; }
514
+ [data-slot='message-content'] h3 { font-size: 1.15em; line-height: 1.4; margin-top: 1.25em; }
515
+ [data-slot='message-content'] h4 { font-size: 1.05em; line-height: 1.4; margin-top: 1.25em; }
516
+ [data-slot='message-content'] h5 { font-size: 1em; line-height: 1.4; margin-top: 1em; }
517
+ [data-slot='message-content'] h6 { font-size: 1em; line-height: 1.4; margin-top: 1em; }
518
+ [data-slot='message-content'] pre { font-size: 0.9em; }
519
+ [data-slot='message-content'] code { font-size: 0.9em; }
520
+ [data-slot='message-content'] pre code { font-size: inherit; }
521
+ /* Table, cells, and headers: Streamdown puts `text-sm` (rem) on td/th directly,
522
+ so overriding the table alone is not enough — the cell class wins. */
523
+ [data-slot='message-content'] table { font-size: 0.95em; }
524
+ [data-slot='message-content'] td { font-size: inherit; }
525
+ [data-slot='message-content'] th { font-size: inherit; }
526
+ /* Code block body wrapper: Streamdown puts `text-sm` on the wrapper div around
527
+ the <pre>; override so it inherits the 0.9em from the pre rule above. */
528
+ [data-slot='message-content'] [data-streamdown='code-block-body'] { font-size: 0.9em; }
529
+ /* Inline code: Streamdown puts `text-sm` directly on the <code>. */
530
+ [data-slot='message-content'] :not(pre) > code { font-size: 0.9em; }
531
+ /* The composer's prompt area and its chips inherit the panel's font size so the
532
+ prompt and the output read at the same scale. The two-selector specificity
533
+ beats the utility class on the element. */
534
+ [data-slot='session-panel'] .prompt-area-container {
535
+ font-size: var(--wd-font-size, var(--text-body-sm));
536
+ }
537
+ [data-slot='session-panel'] .prompt-area-chip {
538
+ font-size: var(--wd-font-size, 0.8125rem);
539
+ }
540
+
488
541
  /* The inset frame's gradient "shine" edge (app shells). Do NOT add bg-* on top. */
489
542
  .frame-shine {
490
543
  border: 1px solid transparent;
@@ -672,7 +725,7 @@
672
725
  overflow: hidden;
673
726
  white-space: nowrap;
674
727
  text-overflow: ellipsis;
675
- font-size: var(--text-body-sm);
728
+ font-size: var(--wd-font-size, var(--text-body-sm));
676
729
  color: var(--fg-2);
677
730
  background: color-mix(in srgb, var(--bg) 88%, transparent);
678
731
  backdrop-filter: blur(12px);