@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.
- package/README.md +45 -1
- package/components/AnnotationPanel.tsx +96 -4
- package/components/AnnotationToolbar.tsx +25 -25
- package/components/CommentPopover.tsx +26 -0
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +22 -2
- package/components/MermaidBlock.tsx +60 -26
- package/components/Settings.tsx +40 -1
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/html-viewer/HtmlViewer.tsx +115 -5
- package/components/html-viewer/bridge-script.ts +41 -7
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +95 -5
- package/configure.ts +32 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useSharing.ts +31 -5
- package/package.json +4 -2
- package/styles.css +1 -1
- package/types.ts +1 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/identity-tater.ts +36 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +38 -0
- package/utils/quickLabels.ts +13 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- 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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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">
|
package/components/Settings.tsx
CHANGED
|
@@ -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
|
|
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
|
|
881
|
-
//
|
|
882
|
-
//
|
|
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}
|