@plannotator/ui 0.31.0 → 0.32.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 (41) hide show
  1. package/README.md +45 -1
  2. package/components/AnnotationPanel.tsx +96 -4
  3. package/components/AnnotationToolbar.tsx +25 -25
  4. package/components/CommentPopover.tsx +26 -0
  5. package/components/GraphvizBlock.tsx +86 -7
  6. package/components/HtmlSurfaceControls.tsx +170 -0
  7. package/components/InlineMarkdown.tsx +22 -2
  8. package/components/MermaidBlock.tsx +60 -26
  9. package/components/Settings.tsx +40 -1
  10. package/components/blocks/MathBlock.tsx +26 -14
  11. package/components/html-viewer/HtmlViewer.tsx +115 -5
  12. package/components/html-viewer/bridge-script.ts +41 -7
  13. package/components/html-viewer/hostThreads.ts +37 -0
  14. package/components/html-viewer/index.ts +9 -0
  15. package/components/html-viewer/unanchored.ts +47 -0
  16. package/components/html-viewer/useHtmlAnnotation.ts +95 -5
  17. package/configure.ts +32 -0
  18. package/hooks/useHtmlRefresh.ts +149 -0
  19. package/hooks/useMathRenderer.ts +30 -0
  20. package/hooks/useSharing.ts +31 -5
  21. package/package.json +4 -2
  22. package/styles.css +1 -1
  23. package/types.ts +1 -0
  24. package/utils/generateIdentity.ts +64 -14
  25. package/utils/identity-tater.ts +36 -0
  26. package/utils/math-eager.ts +25 -0
  27. package/utils/math.ts +146 -0
  28. package/utils/mermaid-eager.ts +28 -0
  29. package/utils/mermaid.ts +132 -0
  30. package/utils/parser.ts +38 -0
  31. package/utils/quickLabels.ts +13 -0
  32. package/webmcp/activity.ts +46 -0
  33. package/webmcp/changes.ts +227 -0
  34. package/webmcp/index.ts +72 -0
  35. package/webmcp/modelContext.ts +103 -0
  36. package/webmcp/nudges.ts +174 -0
  37. package/webmcp/policy.ts +50 -0
  38. package/webmcp/preference.ts +50 -0
  39. package/webmcp/schema.ts +81 -0
  40. package/webmcp/toolset.ts +337 -0
  41. 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
@@ -17,6 +17,7 @@ import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../..
17
17
  import { AnnotationType } from "../../types";
18
18
  import { copyTextPreservingFocus } from "../../utils/clipboard";
19
19
  import { getIdentity } from "../../utils/identity";
20
+ import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
20
21
  import {
21
22
  createVimHudCommand,
22
23
  getVimHudPhase,
@@ -37,6 +38,7 @@ import {
37
38
  type ComposerYieldState,
38
39
  } from "./composerYield";
39
40
  import { buildSyncNumbering } from "./annotationNumbering";
41
+ import { mergeUnanchoredIds } from "./unanchored";
40
42
  import {
41
43
  MAX_PAGE_URL_LENGTH,
42
44
  rejectsLiveMessage,
@@ -202,8 +204,21 @@ export interface HtmlViewerProps {
202
204
  /** Reports the full set of annotation ids with no live representation on
203
205
  * the page (fail-closed anchors hide markers rather than guess). Called
204
206
  * with the complete current set whenever it changes, including back to
205
- * empty on recovery. Fires in readOnly mode too. */
207
+ * empty on recovery. Fires in readOnly mode too. Complete over the
208
+ * `annotations` prop: page rows with nothing to restore by (no quoted
209
+ * text, no element anchor) are reported even though the bridge never
210
+ * sees them, and an id this viewer minted for a local comment that the
211
+ * host swapped out of `annotations` for its own id is not reported. */
206
212
  onUnanchoredChange?: (ids: string[]) => void;
213
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
214
+ * Enforced at the trust boundary, on submit and on restore, and carried
215
+ * to the bridge so the in-page toggle stops at the same number. Default
216
+ * 16 (the package cap); absent leaves every message unchanged. */
217
+ maxAdditionalTargets?: number;
218
+ /** scrollIntoView behavior when a selected annotation is scrolled into
219
+ * view inside the page. Default 'smooth'; pass 'auto' to carry the
220
+ * parent's reduced-motion preference across the iframe boundary. */
221
+ scrollBehavior?: 'smooth' | 'auto';
207
222
  /** Accessible iframe title. */
208
223
  title?: string;
209
224
  }
@@ -245,6 +260,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
245
260
  onAskAI,
246
261
  readOnly = false,
247
262
  onUnanchoredChange,
263
+ maxAdditionalTargets,
264
+ scrollBehavior,
248
265
  title = "HTML Plan Viewer",
249
266
  },
250
267
  ref,
@@ -358,6 +375,63 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
358
375
  [handleYieldPointer],
359
376
  );
360
377
 
378
+ // Unanchored union: the bridge reports ids with no live representation,
379
+ // completed here with what the bridge cannot see (textless page rows it
380
+ // was never asked to restore) and minus locally minted ids the host
381
+ // swapped out of `annotations`. Bridge reports deliver as they arrive
382
+ // (pass-through timing); a prop-side change delivers only when the union
383
+ // actually changes, so a viewer with nothing to complete delivers exactly
384
+ // the bridge list, exactly when the bridge posts it.
385
+ const onUnanchoredChangeRef = useRef(onUnanchoredChange);
386
+ onUnanchoredChangeRef.current = onUnanchoredChange;
387
+ const annotationsRef = useRef(annotations);
388
+ annotationsRef.current = annotations;
389
+ const bridgeUnanchoredRef = useRef<readonly string[]>([]);
390
+ const lastDeliveredUnanchoredRef = useRef("[]");
391
+ const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
392
+ // The bridge's first report for the current document is the one that
393
+ // follows the restore batch (the parent asks for it with
394
+ // report-unanchored, and the bridge answers after its next complete
395
+ // pass, empty set included). Nothing is delivered before it: a host
396
+ // that acknowledges "the first report after a reload" must get the
397
+ // post-restore set, never a prop-side set computed at mount.
398
+ const bridgeReportedRef = useRef(false);
399
+ // An in-place document swap (rawHtml or src changes on one instance):
400
+ // the old document may still emit through the same contentWindow before
401
+ // the new one is ready. From the swap until the next ready, bridge
402
+ // reports belong to the old document and are dropped, and the
403
+ // last-delivered key is reset so the new document's first answer is
404
+ // delivered even when it equals the old one. First mount is not a swap.
405
+ const awaitingReadyRef = useRef(false);
406
+ const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
407
+ const previousIdentity = documentIdentityRef.current;
408
+ if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
409
+ awaitingReadyRef.current = true;
410
+ bridgeReportedRef.current = false;
411
+ lastDeliveredUnanchoredRef.current = "[]";
412
+ bridgeUnanchoredRef.current = [];
413
+ }
414
+ documentIdentityRef.current = { rawHtml, src };
415
+ const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
416
+ const key = JSON.stringify(ids);
417
+ if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
418
+ lastDeliveredUnanchoredRef.current = key;
419
+ onUnanchoredChangeRef.current?.(ids);
420
+ }, []);
421
+ const handleBridgeUnanchored = useCallback((ids: string[]) => {
422
+ if (awaitingReadyRef.current) return;
423
+ bridgeUnanchoredRef.current = ids;
424
+ bridgeReportedRef.current = true;
425
+ deliverUnanchored(
426
+ mergeUnanchoredIds({
427
+ bridgeIds: ids,
428
+ annotations: annotationsRef.current,
429
+ createdIds: createdIdsRef.current,
430
+ }),
431
+ false,
432
+ );
433
+ }, [deliverUnanchored]);
434
+
361
435
  const hook = useHtmlAnnotation({
362
436
  iframeRef,
363
437
  enabled: !readOnly,
@@ -370,8 +444,23 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
370
444
  live: liveSession,
371
445
  onPageChange,
372
446
  onBridgePointer: handleBridgePointer,
373
- onUnanchoredChange,
447
+ onUnanchoredChange: handleBridgeUnanchored,
448
+ maxAdditionalTargets,
449
+ scrollBehavior,
374
450
  });
451
+ createdIdsRef.current = hook.createdAnnotationIds;
452
+
453
+ useEffect(() => {
454
+ if (!bridgeReportedRef.current) return;
455
+ deliverUnanchored(
456
+ mergeUnanchoredIds({
457
+ bridgeIds: bridgeUnanchoredRef.current,
458
+ annotations,
459
+ createdIds: hook.createdAnnotationIds,
460
+ }),
461
+ true,
462
+ );
463
+ }, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
375
464
 
376
465
  const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
377
466
 
@@ -547,6 +636,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
547
636
  if (restorable.length > 0) {
548
637
  hook.applyAnnotations(restorable);
549
638
  }
639
+ // A fresh document: the bridge starts from an empty set and would stay
640
+ // silent when everything restores. Ask for one complete report after
641
+ // this restore batch (posted after it, so the answering pass sees it),
642
+ // which becomes this document's first delivery, empty set included.
643
+ awaitingReadyRef.current = false;
644
+ bridgeReportedRef.current = false;
645
+ postToBridge({ type: `${PREFIX}report-unanchored` });
550
646
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
551
647
 
552
648
  // Live page navigation with a ready iframe: explicitly clear the previous
@@ -569,6 +665,11 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
569
665
  type: `${PREFIX}sync-annotations`,
570
666
  annotations: buildSyncNumbering(annotations),
571
667
  });
668
+ // A new page is a new restore batch: report its complete set once, and
669
+ // deliver nothing computed against the previous page's report until
670
+ // that answer arrives.
671
+ bridgeReportedRef.current = false;
672
+ postToBridge({ type: `${PREFIX}report-unanchored` });
572
673
  }, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
573
674
 
574
675
  // Placed-marker numbering is parent-authoritative and matches the
@@ -877,10 +978,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
877
978
  positionMode="center-above"
878
979
  element={hook.toolbarState.element}
879
980
  copyText={hook.toolbarState.selectionText}
880
- // HTML/live surfaces are comment-only: no Delete, no quick
881
- // labels (onQuickLabel deliberately not passed). The markdown
882
- // surface keeps the full toolbar.
981
+ // HTML/live surfaces are comment-only: no Delete, no label
982
+ // picker, no Alt+digit labels (commentOnly). Exactly ONE label
983
+ // affordance is restored: the hardcoded 👍 "Looks good". The
984
+ // wrapper filters by id as defense in depth, so no present or
985
+ // future toolbar path can emit an arbitrary label here.
883
986
  commentOnly
987
+ onQuickLabel={(label) => {
988
+ if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
989
+ }}
884
990
  onAnnotate={hook.handleAnnotate}
885
991
  onRequestComment={hook.handleRequestComment}
886
992
  onClose={hook.handleToolbarClose}
@@ -898,6 +1004,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
898
1004
  isGlobal={false}
899
1005
  draftKey={`html:${hook.commentPopover.draftKey}`}
900
1006
  onSubmit={hook.handleCommentSubmit}
1007
+ // Pinpoint clicks open this composer directly, so it carries
1008
+ // the surface's one-click "Looks good" (the global composer
1009
+ // does not: a document-wide thumbs-up is not a thing).
1010
+ onQuickLookGood={hook.handleCommentLooksGood}
901
1011
  onClose={hook.handleCommentClose}
902
1012
  skillReferences
903
1013
  onAskAI={onAskAI}