@plannotator/ui 0.31.0 → 0.33.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 (46) hide show
  1. package/HANDOFF.md +664 -0
  2. package/README.md +57 -1
  3. package/components/AnnotationPanel.tsx +96 -4
  4. package/components/AnnotationToolbar.tsx +25 -25
  5. package/components/CommentPopover.tsx +26 -0
  6. package/components/GraphvizBlock.tsx +86 -7
  7. package/components/HtmlSurfaceControls.tsx +170 -0
  8. package/components/InlineMarkdown.tsx +22 -2
  9. package/components/MermaidBlock.tsx +60 -26
  10. package/components/Settings.tsx +40 -1
  11. package/components/blocks/MathBlock.tsx +26 -14
  12. package/components/html-viewer/HtmlViewer.tsx +289 -6
  13. package/components/html-viewer/bridge-script.asset.js +4392 -0
  14. package/components/html-viewer/bridge-script.lite.ts +9 -0
  15. package/components/html-viewer/bridge-script.ts +54 -8
  16. package/components/html-viewer/hostThreads.ts +37 -0
  17. package/components/html-viewer/index.ts +22 -1
  18. package/components/html-viewer/srcdoc.ts +70 -1
  19. package/components/html-viewer/unanchored.ts +47 -0
  20. package/components/html-viewer/useHtmlAnnotation.ts +132 -5
  21. package/configure.ts +32 -0
  22. package/hooks/useHtmlRefresh.ts +149 -0
  23. package/hooks/useMathRenderer.ts +30 -0
  24. package/hooks/useSharing.ts +31 -5
  25. package/package.json +9 -3
  26. package/styles.css +1 -1
  27. package/types.ts +1 -0
  28. package/utils/generateIdentity.ts +64 -14
  29. package/utils/identity-tater.ts +36 -0
  30. package/utils/math-default-loader.ts +24 -0
  31. package/utils/math-eager.ts +25 -0
  32. package/utils/math.ts +149 -0
  33. package/utils/mermaid-eager.ts +28 -0
  34. package/utils/mermaid.ts +132 -0
  35. package/utils/parser.ts +38 -0
  36. package/utils/quickLabels.ts +13 -0
  37. package/webmcp/activity.ts +46 -0
  38. package/webmcp/changes.ts +227 -0
  39. package/webmcp/index.ts +72 -0
  40. package/webmcp/modelContext.ts +103 -0
  41. package/webmcp/nudges.ts +174 -0
  42. package/webmcp/policy.ts +50 -0
  43. package/webmcp/preference.ts +50 -0
  44. package/webmcp/schema.ts +81 -0
  45. package/webmcp/toolset.ts +337 -0
  46. package/webmcp/useToolset.ts +74 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Header controls for a raw-HTML or live-app annotation surface: the eye
3
+ * (show/hide the floating tools over the page), the optional refresh, and
4
+ * the pen (Annotate/Interact toggle). Presentation only; every state lives
5
+ * in the host. Each control renders only when its handler is passed, so a
6
+ * read-only document can show the eye without a pen.
7
+ *
8
+ * The markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`,
9
+ * `data-html-annotate-toggle`), aria state and the pixel-stable pen border
10
+ * are the exact ones Plannotator's header shipped with; hosts get the same
11
+ * control, and `labels` overrides the strings without touching the DOM.
12
+ */
13
+
14
+ /** String overrides. Every key optional; defaults are Plannotator's strings. */
15
+ export interface HtmlSurfaceControlLabels {
16
+ /** Pen title while Annotate is armed. */
17
+ annotateTitle?: string;
18
+ /** Pen title while in Interact mode. */
19
+ interactTitle?: string;
20
+ /** Pen aria-label while armed. Default: none (the title carries the name). */
21
+ annotateLabel?: string;
22
+ /** Pen aria-label while in Interact mode. Default: none. */
23
+ interactLabel?: string;
24
+ /** Eye title and screen-reader text while the tools are visible. */
25
+ hideTools?: string;
26
+ /** Eye title and screen-reader text while the tools are hidden. */
27
+ showTools?: string;
28
+ /** Refresh visible text while idle. */
29
+ refresh?: string;
30
+ /** Refresh visible text while a refresh is in flight. */
31
+ refreshing?: string;
32
+ /** Refresh title and aria-label while idle. */
33
+ refreshTitle?: string;
34
+ /** Refresh title and aria-label while a refresh is in flight. */
35
+ refreshingTitle?: string;
36
+ }
37
+
38
+ export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
39
+ Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
40
+ > = {
41
+ annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
42
+ interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
43
+ hideTools: 'Hide tools',
44
+ showTools: 'Show tools',
45
+ refresh: 'Refresh',
46
+ refreshing: 'Refreshing',
47
+ refreshTitle: 'Refresh document',
48
+ refreshingTitle: 'Refreshing document',
49
+ };
50
+
51
+ export interface HtmlSurfaceControlsProps {
52
+ /** Whether Annotate is armed (pen pressed). */
53
+ armed: boolean;
54
+ /** Flip Annotate/Interact. The pen renders only when provided. */
55
+ onToggleArmed?: () => void;
56
+ /** Whether the floating tools over the page are hidden (eye-off). */
57
+ toolsHidden?: boolean;
58
+ /** Flip the tools. The eye (and the refresh beside it) render only when provided. */
59
+ onToggleTools?: () => void;
60
+ /** Whether a refresh is offered for this document. */
61
+ canRefresh?: boolean;
62
+ onRefresh?: () => void;
63
+ isRefreshing?: boolean;
64
+ /** Compact touch shells put these actions in a menu instead: render nothing. */
65
+ compact?: boolean;
66
+ labels?: HtmlSurfaceControlLabels;
67
+ }
68
+
69
+ export function HtmlSurfaceControls({
70
+ armed,
71
+ onToggleArmed,
72
+ toolsHidden = false,
73
+ onToggleTools,
74
+ canRefresh = false,
75
+ onRefresh,
76
+ isRefreshing = false,
77
+ compact = false,
78
+ labels,
79
+ }: HtmlSurfaceControlsProps) {
80
+ if (compact) return null;
81
+ const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
82
+ const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
83
+ return (
84
+ <>
85
+ {/* Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
86
+ the comment/attachments cluster) from the DOM, leaving nothing over
87
+ the page. Sits left of the pen; this button is the only way back,
88
+ so it never hides itself. Eye = tools visible, eye-off = hidden. */}
89
+ {onToggleTools && (
90
+ <div className="ml-1 flex items-center gap-0.5">
91
+ {canRefresh && onRefresh && (
92
+ <button
93
+ type="button"
94
+ data-html-refresh
95
+ // aria-disabled rather than disabled: a disabled control drops
96
+ // keyboard focus to body when activated. useHtmlRefresh already
97
+ // dedups in-flight requests, so an extra click is harmless.
98
+ onClick={isRefreshing ? undefined : onRefresh}
99
+ aria-disabled={isRefreshing}
100
+ className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground aria-disabled:cursor-wait aria-disabled:opacity-70"
101
+ title={isRefreshing ? text.refreshingTitle : text.refreshTitle}
102
+ aria-label={isRefreshing ? text.refreshingTitle : text.refreshTitle}
103
+ >
104
+ <svg
105
+ aria-hidden="true"
106
+ viewBox="0 0 24 24"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ strokeWidth="2"
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin motion-reduce:animate-none' : ''}`}
113
+ >
114
+ <path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5" />
115
+ <path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5" />
116
+ </svg>
117
+ <span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
118
+ </button>
119
+ )}
120
+ <button
121
+ type="button"
122
+ data-html-tools-toggle
123
+ onClick={onToggleTools}
124
+ aria-pressed={toolsHidden}
125
+ className="cursor-pointer rounded-md border border-transparent p-1.5 text-xs font-medium text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
126
+ title={toolsHidden ? text.showTools : text.hideTools}
127
+ >
128
+ {toolsHidden ? (
129
+ <svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
130
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
131
+ </svg>
132
+ ) : (
133
+ <svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
134
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
135
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
136
+ </svg>
137
+ )}
138
+ <span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
139
+ </button>
140
+ </div>
141
+ )}
142
+
143
+ {/* Interact/Annotate toggle. A PEN icon (deliberately not a speech
144
+ bubble: an annotations-panel bubble beside it must stay
145
+ distinguishable at a glance, and so must AI sparkles). Always the
146
+ same icon: armed shows the accent color plus a visible border;
147
+ unarmed is muted with a TRANSPARENT border of the same width, so
148
+ the button's box is pixel-identical in both states. */}
149
+ {onToggleArmed && (
150
+ <button
151
+ type="button"
152
+ data-html-annotate-toggle
153
+ onClick={onToggleArmed}
154
+ aria-pressed={armed}
155
+ className={`p-1.5 rounded-md border text-xs font-medium transition-all cursor-pointer ${
156
+ armed
157
+ ? 'border-primary/60 bg-primary/15 text-primary'
158
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted'
159
+ }`}
160
+ title={armed ? text.annotateTitle : text.interactTitle}
161
+ {...(penLabel !== undefined ? { 'aria-label': penLabel } : {})}
162
+ >
163
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
164
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487zm0 0L19.5 7.125" />
165
+ </svg>
166
+ </button>
167
+ )}
168
+ </>
169
+ );
170
+ }
@@ -9,7 +9,8 @@ import { getImageSrc } from "./ImageThumbnail";
9
9
  import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
10
10
  import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
11
11
  import { CodeFilePicker } from "./CodeFilePicker";
12
- import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock";
12
+ import { normalizeMathTex, renderMathToHtml } from "../utils/math";
13
+ import { useMathRenderer } from "../hooks/useMathRenderer";
13
14
 
14
15
  export interface DocPreviewResult {
15
16
  contents?: string;
@@ -42,6 +43,9 @@ export const resetDocPreviewFetcher = (): void => {
42
43
  docPreviewFetcher = defaultDocPreviewFetcher;
43
44
  };
44
45
 
46
+ /** Read the active fetcher at call time (so a late override is honored). */
47
+ export const getDocPreviewFetcher = (): DocPreviewFetcher => docPreviewFetcher;
48
+
45
49
  /**
46
50
  * Decide how a candidate code-file path should render based on validation state:
47
51
  * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput)
@@ -309,7 +313,23 @@ const CodeFileIcon = () => (
309
313
 
310
314
  const InlineMath: React.FC<{ tex: string }> = ({ tex }) => {
311
315
  const normalizedTex = normalizeMathTex(tex);
312
- const html = useMemo(() => renderMathToHtml(normalizedTex, false), [normalizedTex]);
316
+ const renderer = useMathRenderer();
317
+ const html = useMemo(() => renderMathToHtml(normalizedTex, false, renderer), [normalizedTex, renderer]);
318
+
319
+ // Same wrapper in both branches (see MathBlock): the placeholder carries the
320
+ // attributes annotation restore keys on, with the TeX as a text child.
321
+ if (html === null) {
322
+ return (
323
+ <span
324
+ className="math-inline math-annotatable text-foreground"
325
+ data-math-tex={normalizedTex}
326
+ data-math-display="false"
327
+ aria-label={normalizedTex}
328
+ >
329
+ {normalizedTex}
330
+ </span>
331
+ );
332
+ }
313
333
 
314
334
  return (
315
335
  <span
@@ -1,33 +1,26 @@
1
1
  import React, { useRef, useState, useEffect, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import mermaid from 'mermaid';
3
+ import type { Mermaid } from 'mermaid';
4
4
  import type { Block } from '../types';
5
5
  import { normalizeMermaidSvgMarkup } from './mermaidSvg';
6
+ import {
7
+ MERMAID_CONFIG,
8
+ getMermaidRetryDelayMs,
9
+ loadMermaidRuntime,
10
+ __setMermaidRuntimeLoaderForTests,
11
+ } from '../utils/mermaid';
6
12
 
7
- mermaid.initialize({
8
- startOnLoad: false,
9
- securityLevel: 'strict',
10
- theme: 'dark',
11
- themeVariables: {
12
- primaryColor: '#3b82f6',
13
- primaryTextColor: '#f8fafc',
14
- primaryBorderColor: '#475569',
15
- lineColor: '#64748b',
16
- secondaryColor: '#1e293b',
17
- tertiaryColor: '#0f172a',
18
- background: '#1e293b',
19
- mainBkg: '#1e293b',
20
- nodeBorder: '#475569',
21
- clusterBkg: '#1e293b',
22
- clusterBorder: '#475569',
23
- titleColor: '#f8fafc',
24
- edgeLabelBackground: '#1e293b',
25
- },
26
- flowchart: {
27
- htmlLabels: true,
28
- curve: 'basis',
29
- },
30
- });
13
+ // Re-exported: the config pin test and the lazy-retry test import them from here.
14
+ export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
15
+
16
+ /**
17
+ * The runtime comes from the slot in utils/mermaid: filled eagerly by
18
+ * Plannotator (utils/mermaid-eager, imported by the editor App), loaded
19
+ * lazily otherwise. See that module for the retry contract.
20
+ */
21
+ const getMermaid = loadMermaidRuntime;
22
+
23
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
31
24
 
32
25
  interface ViewBox {
33
26
  x: number;
@@ -133,6 +126,11 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
133
126
  const expandedOverlayRef = useRef<HTMLDivElement>(null);
134
127
  const [svg, setSvg] = useState('');
135
128
  const [error, setError] = useState<string | null>(null);
129
+ // True when the failure was the runtime import itself (a chunking host's
130
+ // fetch), which is the only failure a Retry can change; a diagram syntax
131
+ // error keeps the panel exactly as it always was.
132
+ const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
133
+ const [retryToken, setRetryToken] = useState(0);
136
134
  const [showSource, setShowSource] = useState(false);
137
135
  const [isExpanded, setIsExpanded] = useState(false);
138
136
 
@@ -188,6 +186,27 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
188
186
 
189
187
  // Render mermaid diagram
190
188
  const renderDiagram = async () => {
189
+ let mermaid: Mermaid;
190
+ try {
191
+ try {
192
+ mermaid = await getMermaid();
193
+ } catch {
194
+ // Transient chunk failure on a chunking host: one automatic
195
+ // re-attempt with a fresh import() after a short delay. In a
196
+ // single-file build the first await never rejects, so this branch
197
+ // is unreachable there and the success path is unchanged.
198
+ await wait(getMermaidRetryDelayMs());
199
+ if (cancelled) return;
200
+ mermaid = await getMermaid();
201
+ }
202
+ } catch (err) {
203
+ if (!cancelled) {
204
+ setError(err instanceof Error ? err.message : 'Failed to render diagram');
205
+ setRuntimeUnavailable(true);
206
+ setSvg('');
207
+ }
208
+ return;
209
+ }
191
210
  try {
192
211
  const id = `mermaid-${block.id}`;
193
212
  const { svg: renderedSvg } = await mermaid.render(id, block.content);
@@ -196,10 +215,12 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
196
215
  naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
197
216
  setSvg(normalizedSvg);
198
217
  setError(null);
218
+ setRuntimeUnavailable(false);
199
219
  }
200
220
  } catch (err) {
201
221
  if (!cancelled) {
202
222
  setError(err instanceof Error ? err.message : 'Failed to render diagram');
223
+ setRuntimeUnavailable(false);
203
224
  setSvg('');
204
225
  }
205
226
  }
@@ -210,7 +231,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
210
231
  return () => {
211
232
  cancelled = true;
212
233
  };
213
- }, [block.content, block.id]);
234
+ }, [block.content, block.id, retryToken]);
214
235
 
215
236
  // Reset zoom and pan when content changes
216
237
  useEffect(() => {
@@ -419,6 +440,19 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
419
440
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
420
441
  </svg>
421
442
  <span className="text-xs text-destructive font-medium">Mermaid Error</span>
443
+ {runtimeUnavailable && (
444
+ <button
445
+ type="button"
446
+ onClick={() => {
447
+ setError(null);
448
+ setRetryToken((token) => token + 1);
449
+ }}
450
+ className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
451
+ title="Retry loading the diagram renderer"
452
+ >
453
+ Retry
454
+ </button>
455
+ )}
422
456
  </div>
423
457
  <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
424
458
  <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
@@ -4,6 +4,7 @@ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal
4
4
  import type { Origin } from '@plannotator/core/agents';
5
5
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
6
6
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
7
+ import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
7
8
  import { loadDiffFont } from '../utils/diffFonts';
8
9
  import { TaterSpritePullup } from './TaterSpritePullup';
9
10
  import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
@@ -107,6 +108,10 @@ interface SettingsProps {
107
108
  * from a Hidden position, but offering it where no terminal can ever run
108
109
  * would just be a dead control. */
109
110
  agentTerminalAvailable?: boolean;
111
+ /** The browser exposes WebMCP (`document.modelContext`), so the "Agent
112
+ * tools" opt-out is worth showing. Default false: a browser without the
113
+ * API gets no row at all (the provider registers nothing there). */
114
+ webmcpAvailable?: boolean;
110
115
  }
111
116
 
112
117
  // --- Review-mode Display tab (diff display options) ---
@@ -848,7 +853,8 @@ const CommentsTab: React.FC = () => {
848
853
  );
849
854
  };
850
855
 
851
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false }) => {
856
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
857
+ const webmcpTools = useWebMcpToolsEnabled();
852
858
  const [showDialog, setShowDialog] = useState(false);
853
859
  const settingsWasOpenRef = useRef(false);
854
860
  const [themePreview, setThemePreview] = useState(false);
@@ -1282,6 +1288,39 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1282
1288
  </div>
1283
1289
  </div>
1284
1290
 
1291
+ {/* Agent tools (WebMCP). Shown only when the browser
1292
+ exposes document.modelContext; off unregisters the
1293
+ tools for this browser. Cookie-only and idle at its
1294
+ default: no cookie exists until the user opts out. */}
1295
+ {webmcpAvailable && (
1296
+ <>
1297
+ <div className="border-t border-border" />
1298
+ <div className="flex items-center justify-between" data-webmcp-setting="true">
1299
+ <div>
1300
+ <div className="text-sm font-medium">Agent tools</div>
1301
+ <div className="text-xs text-muted-foreground">
1302
+ Let your browser's agent read this document and leave comments. Approving and sending feedback stay yours.
1303
+ </div>
1304
+ </div>
1305
+ <button
1306
+ role="switch"
1307
+ aria-checked={webmcpTools}
1308
+ aria-label="Agent tools"
1309
+ onClick={() => setWebMcpToolsEnabled(!webmcpTools)}
1310
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1311
+ webmcpTools ? 'bg-primary' : 'bg-muted'
1312
+ }`}
1313
+ >
1314
+ <span
1315
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1316
+ webmcpTools ? 'translate-x-6' : 'translate-x-1'
1317
+ }`}
1318
+ />
1319
+ </button>
1320
+ </div>
1321
+ </>
1322
+ )}
1323
+
1285
1324
  {/* Permission Mode (Claude Code only) */}
1286
1325
  {origin === 'claude-code' && mode === 'plan' && (
1287
1326
  <>
@@ -1,26 +1,38 @@
1
1
  import React, { useMemo } from 'react';
2
- import katex from 'katex';
3
2
  import type { Block } from '../../types';
3
+ import { normalizeMathTex, renderMathToHtml } from '../../utils/math';
4
+ import { useMathRenderer } from '../../hooks/useMathRenderer';
5
+
6
+ // Kept as re-exports: InlineMarkdown and older consumers import them from here.
7
+ export { normalizeMathTex, renderMathToHtml };
4
8
 
5
9
  type MathBlockProps = {
6
10
  block: Block;
7
11
  };
8
12
 
9
- export const normalizeMathTex = (tex: string): string => tex.trim();
10
-
11
- export const renderMathToHtml = (tex: string, displayMode: boolean): string => (
12
- katex.renderToString(tex, {
13
- displayMode,
14
- throwOnError: false,
15
- strict: 'warn',
16
- trust: false,
17
- output: 'html',
18
- })
19
- );
20
-
21
13
  export const MathBlock: React.FC<MathBlockProps> = ({ block }) => {
22
14
  const tex = normalizeMathTex(block.content);
23
- const html = useMemo(() => renderMathToHtml(tex, true), [tex]);
15
+ const renderer = useMathRenderer();
16
+ const html = useMemo(() => renderMathToHtml(tex, true, renderer), [tex, renderer]);
17
+
18
+ // The wrapper (class names, data-* anchors, aria-label) is identical in both
19
+ // branches: block targeting and math-annotation restore key on these
20
+ // attributes, so a placeholder must be addressable exactly like the typeset
21
+ // block. The placeholder's child is a React text node, never markup.
22
+ if (html === null) {
23
+ return (
24
+ <div
25
+ className="math-block math-annotatable my-5 overflow-x-auto py-2 text-foreground"
26
+ data-block-id={block.id}
27
+ data-block-type="math"
28
+ data-math-tex={tex}
29
+ data-math-display="true"
30
+ aria-label={tex}
31
+ >
32
+ {tex}
33
+ </div>
34
+ );
35
+ }
24
36
 
25
37
  return (
26
38
  <div