@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,227 @@
1
+ /**
2
+ * The change tracker behind "since your last read".
3
+ *
4
+ * WebMCP page tools receive no caller identity and the spec's model is one
5
+ * agent per tab, so the watermark is per tab, per page load, and any agent
6
+ * that wants its own passes `since` explicitly (every response returns
7
+ * `cursor`). Two agents on one tab share the implicit watermark; that is a
8
+ * documented limitation.
9
+ *
10
+ * Pure, no DOM, so it can move to `@plannotator/core` untouched.
11
+ */
12
+
13
+ /** `source` stamped on every annotation a browser agent creates through the tools. */
14
+ export const BROWSER_AGENT_SOURCE = 'browser-agent';
15
+
16
+ /** The slice of an annotation the tracker hashes. */
17
+ export interface TrackedAnnotation {
18
+ id: string;
19
+ text?: string;
20
+ originalText?: string;
21
+ source?: string;
22
+ inReplyTo?: string;
23
+ images?: ReadonlyArray<{ path: string }>;
24
+ }
25
+
26
+ export interface ChangeEntry {
27
+ seq: number;
28
+ hash: string;
29
+ agent: boolean;
30
+ }
31
+
32
+ export interface Tombstone {
33
+ id: string;
34
+ seq: number;
35
+ /** The removed annotation was agent-authored (its `source` was the agent stamp). */
36
+ agent: boolean;
37
+ }
38
+
39
+ export interface ObserveDelta {
40
+ added: string[];
41
+ changed: string[];
42
+ removed: Tombstone[];
43
+ }
44
+
45
+ export function isAgentAnnotation(annotation: { source?: string }): boolean {
46
+ return annotation.source === BROWSER_AGENT_SOURCE;
47
+ }
48
+
49
+ export function hashAnnotation(annotation: TrackedAnnotation): string {
50
+ return JSON.stringify([
51
+ annotation.text ?? '',
52
+ annotation.originalText ?? '',
53
+ annotation.inReplyTo ?? '',
54
+ annotation.images?.map((image) => image.path) ?? [],
55
+ ]);
56
+ }
57
+
58
+ const CURSOR_PREFIX = 'w:';
59
+
60
+ export class AnnotationChangeTracker {
61
+ private readonly entries = new Map<string, ChangeEntry>();
62
+ private readonly tombstones = new Map<string, Tombstone>();
63
+ /** Hash the agent wrote for an id; the seq assigned to that exact state is never "new" to the agent. */
64
+ private readonly ownHashes = new Map<string, string>();
65
+ private readonly ownSeqs = new Map<string, number>();
66
+ /** Ids the agent removed itself; their tombstones are never reported back to it. */
67
+ private readonly agentRemoved = new Set<string>();
68
+ private counter = 0;
69
+ private mark = 0;
70
+ /** Wall-clock time of the last seq change (null until anything changes). */
71
+ lastActivity: number | null = null;
72
+
73
+ constructor(private readonly now: () => number = () => Date.now()) {}
74
+
75
+ /** Highest seq handed out so far. */
76
+ get seq(): number {
77
+ return this.counter;
78
+ }
79
+
80
+ /** The implicit per-tab watermark. */
81
+ get watermark(): number {
82
+ return this.mark;
83
+ }
84
+
85
+ cursor(): string {
86
+ return `${CURSOR_PREFIX}${this.counter}`;
87
+ }
88
+
89
+ /** Accepts a `cursor` string (`w:47`) or a bare number; anything else is null. */
90
+ static parseSince(value: unknown): number | null {
91
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value);
92
+ if (typeof value === 'string') {
93
+ const digits = value.startsWith(CURSOR_PREFIX) ? value.slice(CURSOR_PREFIX.length) : value;
94
+ if (/^\d+$/.test(digits)) return Number(digits);
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** Advance the implicit watermark (default: to the current seq). */
100
+ advance(to: number = this.counter): void {
101
+ this.mark = Math.max(this.mark, Math.min(to, this.counter));
102
+ }
103
+
104
+ /**
105
+ * Claim an annotation state as the agent's own: when the tracker next sees
106
+ * this id with this exact hash, that seq is remembered so `newSince` skips
107
+ * it. A later human edit produces a different hash, a new seq, and IS new.
108
+ */
109
+ claimOwn(annotation: TrackedAnnotation): void {
110
+ this.ownHashes.set(annotation.id, hashAnnotation(annotation));
111
+ const entry = this.entries.get(annotation.id);
112
+ if (entry && entry.hash === this.ownHashes.get(annotation.id)) this.ownSeqs.set(annotation.id, entry.seq);
113
+ }
114
+
115
+ /**
116
+ * Record that the agent removed `id` itself, so the resulting tombstone
117
+ * is not attributed to the human (`removedSince` skips it).
118
+ */
119
+ claimRemoved(id: string): void {
120
+ this.agentRemoved.add(id);
121
+ }
122
+
123
+ /** One O(n) pass over the current list; assigns seqs and tombstones. */
124
+ observe(list: ReadonlyArray<TrackedAnnotation>): ObserveDelta {
125
+ const delta: ObserveDelta = { added: [], changed: [], removed: [] };
126
+ const seen = new Set<string>();
127
+ let touched = false;
128
+ for (const annotation of list) {
129
+ seen.add(annotation.id);
130
+ const hash = hashAnnotation(annotation);
131
+ const agent = isAgentAnnotation(annotation);
132
+ const existing = this.entries.get(annotation.id);
133
+ if (existing && existing.hash === hash) {
134
+ existing.agent = agent;
135
+ continue;
136
+ }
137
+ const seq = ++this.counter;
138
+ touched = true;
139
+ this.entries.set(annotation.id, { seq, hash, agent });
140
+ this.tombstones.delete(annotation.id);
141
+ if (existing) delta.changed.push(annotation.id);
142
+ else delta.added.push(annotation.id);
143
+ if (this.ownHashes.get(annotation.id) === hash) this.ownSeqs.set(annotation.id, seq);
144
+ }
145
+ for (const [id, entry] of this.entries) {
146
+ if (seen.has(id)) continue;
147
+ this.entries.delete(id);
148
+ const seq = ++this.counter;
149
+ touched = true;
150
+ const tombstone: Tombstone = { id, seq, agent: entry.agent || this.ownHashes.has(id) };
151
+ this.tombstones.set(id, tombstone);
152
+ delta.removed.push(tombstone);
153
+ }
154
+ // A re-added id is a fresh record: forget any agent-removal claim on it.
155
+ for (const id of seen) this.agentRemoved.delete(id);
156
+ if (touched) this.lastActivity = this.now();
157
+ return delta;
158
+ }
159
+
160
+ seqOf(id: string): number | undefined {
161
+ return this.entries.get(id)?.seq;
162
+ }
163
+
164
+ /** Whether `id` changed after `since` in a way the agent did not author. */
165
+ isNew(id: string, since: number = this.mark): boolean {
166
+ const entry = this.entries.get(id);
167
+ if (!entry || entry.seq <= since) return false;
168
+ return this.ownSeqs.get(id) !== entry.seq;
169
+ }
170
+
171
+ /** Ids added or edited after `since`, excluding states the agent wrote. */
172
+ newSince(since: number = this.mark): string[] {
173
+ const ids: string[] = [];
174
+ for (const [id] of this.entries) if (this.isNew(id, since)) ids.push(id);
175
+ return ids;
176
+ }
177
+
178
+ /** Tombstones written after `since`, excluding removals the agent made itself. */
179
+ removedSince(since: number = this.mark): Tombstone[] {
180
+ const removed: Tombstone[] = [];
181
+ for (const tombstone of this.tombstones.values()) {
182
+ if (tombstone.seq > since && !this.agentRemoved.has(tombstone.id)) removed.push(tombstone);
183
+ }
184
+ return removed;
185
+ }
186
+
187
+ /**
188
+ * Whether the agent claimed `id` in this page load. This is the ownership
189
+ * key for update/remove: a `source` stamp alone can be forged through the
190
+ * external-annotations API, a claim cannot.
191
+ */
192
+ isOwn(id: string): boolean {
193
+ return this.ownHashes.has(id);
194
+ }
195
+
196
+ /** Whether the tracker has ever seen `id` (live or removed). */
197
+ knows(id: string): boolean {
198
+ return this.entries.has(id) || this.tombstones.has(id);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Per-path trackers for folder and linked-doc sessions, each with its own
204
+ * read watermark so `newSinceLastRead` is per document.
205
+ */
206
+ export class ChangeTrackerSet {
207
+ private readonly trackers = new Map<string, AnnotationChangeTracker>();
208
+
209
+ constructor(private readonly now: () => number = () => Date.now()) {}
210
+
211
+ forPath(path: string): AnnotationChangeTracker {
212
+ let tracker = this.trackers.get(path);
213
+ if (!tracker) {
214
+ tracker = new AnnotationChangeTracker(this.now);
215
+ this.trackers.set(path, tracker);
216
+ }
217
+ return tracker;
218
+ }
219
+
220
+ has(path: string): boolean {
221
+ return this.trackers.has(path);
222
+ }
223
+
224
+ paths(): string[] {
225
+ return [...this.trackers.keys()];
226
+ }
227
+ }
@@ -0,0 +1,72 @@
1
+ export {
2
+ MODEL_CONTEXT_PROPERTY,
3
+ TOOL_CHANGE_EVENT,
4
+ TOOL_NAME_PATTERN,
5
+ resolveModelContext,
6
+ type ModelContextLike,
7
+ type ModelContextToolAnnotations,
8
+ type ModelContextToolDescriptor,
9
+ type ModelContextRegisteredTool,
10
+ type ModelContextExecuteContext,
11
+ } from './modelContext';
12
+ export {
13
+ DEFAULT_WEBMCP_NAME_PREFIX,
14
+ getWebMcpPolicy,
15
+ resetWebMcpPolicy,
16
+ setWebMcpPolicy,
17
+ type WebMcpPolicy,
18
+ type ResolvedWebMcpPolicy,
19
+ } from './policy';
20
+ export { validateAgainstSchema, type JsonSchema } from './schema';
21
+ export {
22
+ TOOL_DESCRIPTION_MAX_CHARS,
23
+ TOOL_PARAM_DESCRIPTION_MAX_CHARS,
24
+ createToolRegistry,
25
+ defineTool,
26
+ fail,
27
+ getRegistryFor,
28
+ ok,
29
+ runTool,
30
+ type Nudge,
31
+ type NudgeCode,
32
+ type ToolError,
33
+ type ToolErrorCode,
34
+ type ToolRegistry,
35
+ type ToolResponse,
36
+ type ToolResult,
37
+ type ToolSpec,
38
+ type ToolsetHooks,
39
+ } from './toolset';
40
+ export {
41
+ AnnotationChangeTracker,
42
+ BROWSER_AGENT_SOURCE,
43
+ ChangeTrackerSet,
44
+ hashAnnotation,
45
+ isAgentAnnotation,
46
+ type ObserveDelta,
47
+ type Tombstone,
48
+ type TrackedAnnotation,
49
+ } from './changes';
50
+ export {
51
+ MAX_OTHER_DOCUMENT_NUDGES,
52
+ buildNudges,
53
+ type DocumentSurface,
54
+ type NudgeSnapshot,
55
+ type OtherDocumentActivity,
56
+ } from './nudges';
57
+ export {
58
+ getWebMcpActivity,
59
+ recordToolCall,
60
+ resetWebMcpActivity,
61
+ subscribeWebMcpActivity,
62
+ useWebMcpActivity,
63
+ type WebMcpActivity,
64
+ } from './activity';
65
+ export { useToolset, type UseToolsetOptions, type UseToolsetResult } from './useToolset';
66
+ export {
67
+ WEBMCP_TOOLS_COOKIE,
68
+ getWebMcpToolsEnabled,
69
+ setWebMcpToolsEnabled,
70
+ subscribeWebMcpToolsEnabled,
71
+ useWebMcpToolsEnabled,
72
+ } from './preference';
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The ONLY file in the repo that spells the WebMCP surface.
3
+ *
4
+ * Everything here mirrors the W3C WebML CG draft of WebMCP as of 2026-08-25
5
+ * (spec `index.bs`, entry point `document.modelContext`; the same shape
6
+ * `webmcp-types@0.1.5` publishes). The types are deliberately local and
7
+ * structural: no `declare global`, no dependency. `packages/ui/globals.d.ts`
8
+ * is a published ambient file and must not augment `Document` for every
9
+ * consumer, and the entry point has been renamed three times in a year, so a
10
+ * rename is a one-file change plus its test.
11
+ *
12
+ * Spec facts the engine relies on:
13
+ * - `registerTool(tool, { signal })` returns a promise; unregistration is
14
+ * ONLY via aborting the signal (the abort steps unregister synchronously).
15
+ * - `execute(input, { signal })` runs in our realm; its return value is
16
+ * JSON-serialized by the browser and a rejection or an unserializable value
17
+ * surfaces to the caller as a bare `UnknownError` with no message, so tools
18
+ * must always return a JSON value and report errors as data.
19
+ * - Tool names are 1..128 chars of `[A-Za-z0-9_.-]`.
20
+ * - `annotations` carries `readOnlyHint` and `untrustedContentHint`. The
21
+ * dictionary ignores unknown members, which is why `destructiveHint` (an
22
+ * MCP hint the WebMCP draft does not have yet, issue #176) can be set
23
+ * honestly today at no cost.
24
+ */
25
+
26
+ /** Property on `Document` that holds the per-document ModelContext. */
27
+ export const MODEL_CONTEXT_PROPERTY = 'modelContext';
28
+
29
+ /** Event fired at a document whenever its visible tool set changes. */
30
+ export const TOOL_CHANGE_EVENT = 'toolchange';
31
+
32
+ /** Spec name rule (`index.bs`: 1..128 chars of `[A-Za-z0-9_.-]`). */
33
+ export const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
34
+
35
+ export interface ModelContextToolAnnotations {
36
+ readOnlyHint?: boolean;
37
+ untrustedContentHint?: boolean;
38
+ /** Forward-compatible (MCP hint, WebMCP issue #176). Ignored by today's dictionary. */
39
+ destructiveHint?: boolean;
40
+ }
41
+
42
+ export interface ModelContextExecuteContext {
43
+ signal: AbortSignal;
44
+ }
45
+
46
+ export type ModelContextExecute = (
47
+ input: unknown,
48
+ context: ModelContextExecuteContext,
49
+ ) => unknown | Promise<unknown>;
50
+
51
+ /** `ModelContextTool` dictionary, structural subset. */
52
+ export interface ModelContextToolDescriptor {
53
+ name: string;
54
+ title?: string;
55
+ description: string;
56
+ inputSchema?: Record<string, unknown>;
57
+ execute: ModelContextExecute;
58
+ annotations?: ModelContextToolAnnotations;
59
+ }
60
+
61
+ export interface ModelContextRegisterOptions {
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ /** `RegisteredTool` dictionary, the part `getTools()` callers read. */
66
+ export interface ModelContextRegisteredTool {
67
+ name: string;
68
+ title?: string;
69
+ description: string;
70
+ inputSchema?: Record<string, unknown>;
71
+ annotations?: ModelContextToolAnnotations;
72
+ }
73
+
74
+ /** The nine lines of the API the engine actually uses. */
75
+ export interface ModelContextLike {
76
+ registerTool(tool: ModelContextToolDescriptor, options?: ModelContextRegisterOptions): Promise<unknown>;
77
+ getTools?(options?: Record<string, unknown>): Promise<ModelContextRegisteredTool[]>;
78
+ executeTool?(tool: ModelContextRegisteredTool, input?: object, options?: Record<string, unknown>): Promise<string>;
79
+ addEventListener?(type: string, listener: (event: Event) => void): void;
80
+ removeEventListener?(type: string, listener: (event: Event) => void): void;
81
+ }
82
+
83
+ /**
84
+ * Feature detection: the one `typeof` check the module contributes to a
85
+ * browser without WebMCP. `null` means "no provider", and every layer above
86
+ * treats `null` as "do nothing" (no effects, no DOM, no settings row).
87
+ *
88
+ * House pattern (`utils/clipboard.ts`): `typeof` for the environment, `?.`
89
+ * for the capability, `try/catch` for restricted contexts that throw on
90
+ * property access.
91
+ */
92
+ export function resolveModelContext(doc?: Document | null): ModelContextLike | null {
93
+ try {
94
+ const target = doc ?? (typeof document === 'undefined' ? null : document);
95
+ if (!target) return null;
96
+ const ctx = (target as unknown as Record<string, unknown>)[MODEL_CONTEXT_PROPERTY];
97
+ if (!ctx || typeof ctx !== 'object') return null;
98
+ if (typeof (ctx as ModelContextLike).registerTool !== 'function') return null;
99
+ return ctx as ModelContextLike;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Nudge computation: every response carries `nudges` computed synchronously
3
+ * from state the page already holds. No nudge needs a second call to
4
+ * discover, and every nudge names the ids/paths needed to act on it.
5
+ *
6
+ * Messages are static strings we own. Document text, heading titles and
7
+ * comment text are never concatenated into a message (prompt-injection
8
+ * hygiene); they travel as data in `ids`, `path` and `section`.
9
+ *
10
+ * Pure, no DOM.
11
+ */
12
+
13
+ import type { AnnotationChangeTracker } from './changes';
14
+ import type { Nudge } from './toolset';
15
+
16
+ export type DocumentSurface = 'markdown' | 'html' | 'live-app';
17
+
18
+ export interface OtherDocumentActivity {
19
+ path: string;
20
+ open: boolean;
21
+ annotations: number;
22
+ newSinceLastRead: number;
23
+ composerOpen: boolean;
24
+ /** The human opened this document since the last response. */
25
+ openedSinceLastRead: boolean;
26
+ /** Wall-clock ms of the last change, for ordering; null when never changed. */
27
+ lastActivity?: number | null;
28
+ }
29
+
30
+ export interface NudgeSnapshot {
31
+ surface: DocumentSurface;
32
+ composer: { open: boolean; section?: string };
33
+ sourceStale: boolean;
34
+ documentEdited: boolean;
35
+ /** Live app: the page the human is on now, and the one at the last response. */
36
+ pageUrl: string | null;
37
+ lastPageUrl: string | null;
38
+ annotationCount: number;
39
+ /** The human approved / sent feedback / closed. */
40
+ decided: boolean;
41
+ /** First response of the session (surface hints fire once). */
42
+ firstResponse: boolean;
43
+ /** Set by the response builder when text was windowed: the continuation call's arguments. */
44
+ truncated?: { nextOffset: number; args: Record<string, unknown> };
45
+ /** `inReplyTo` per live annotation id, for `replies_new`. */
46
+ annotations: ReadonlyArray<{ id: string; inReplyTo?: string; source?: string }>;
47
+ otherDocuments: OtherDocumentActivity[];
48
+ /** Watermark the novelty checks run against. */
49
+ since: number;
50
+ }
51
+
52
+ export const MAX_OTHER_DOCUMENT_NUDGES = 10;
53
+
54
+ function plural(count: number, noun: string): string {
55
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
56
+ }
57
+
58
+ export function buildNudges(
59
+ snapshot: NudgeSnapshot,
60
+ tracker: AnnotationChangeTracker,
61
+ toolName: (bare: string) => string,
62
+ ): Nudge[] {
63
+ const nudges: Nudge[] = [];
64
+ const since = snapshot.since;
65
+ const agentIds = new Set(snapshot.annotations.filter((a) => a.source === 'browser-agent').map((a) => a.id));
66
+
67
+ const fresh = tracker.newSince(since);
68
+ const replies = fresh.filter((id) => {
69
+ const parent = snapshot.annotations.find((a) => a.id === id)?.inReplyTo;
70
+ return parent !== undefined && agentIds.has(parent);
71
+ });
72
+ const plain = fresh.filter((id) => !replies.includes(id));
73
+ if (plain.length > 0) {
74
+ nudges.push({
75
+ code: 'annotations_new',
76
+ message: `The human added or edited ${plural(plain.length, 'comment')} since your last read.`,
77
+ ids: plain,
78
+ });
79
+ }
80
+ if (replies.length > 0) {
81
+ nudges.push({
82
+ code: 'replies_new',
83
+ message: `The human replied to your comments (${replies.length} new ${replies.length === 1 ? 'reply' : 'replies'}).`,
84
+ ids: replies,
85
+ });
86
+ }
87
+
88
+ const removed = tracker.removedSince(since);
89
+ if (removed.length > 0) {
90
+ const own = removed.filter((t) => t.agent);
91
+ nudges.push({
92
+ code: 'annotations_removed',
93
+ message: own.length > 0
94
+ ? `The human removed ${own.length} of your comments; treat that as resolved and do not re-add them.`
95
+ : `${plural(removed.length, 'comment')} you had seen ${removed.length === 1 ? 'was' : 'were'} removed.`,
96
+ ids: removed.map((t) => t.id),
97
+ });
98
+ }
99
+
100
+ if (snapshot.composer.open) {
101
+ nudges.push({
102
+ code: 'composer_open',
103
+ message: 'The human is typing a comment right now; wait before commenting on the same passage.',
104
+ ...(snapshot.composer.section ? { section: snapshot.composer.section } : {}),
105
+ });
106
+ }
107
+
108
+ if (snapshot.sourceStale) {
109
+ nudges.push({
110
+ code: 'source_stale',
111
+ message: 'The annotated file changed on disk since it was loaded; the text you read may be behind the file.',
112
+ });
113
+ }
114
+
115
+ if (snapshot.documentEdited) {
116
+ nudges.push({
117
+ code: 'document_edited',
118
+ message: 'The human is editing the document text; the text reflects their buffer and quote anchors may drift.',
119
+ });
120
+ }
121
+
122
+ if (snapshot.firstResponse && snapshot.surface !== 'markdown') {
123
+ nudges.push({
124
+ code: 'comment_only_surface',
125
+ message: 'This surface is comment-only: comments anchor on an exact text quote or on the whole document, and nothing can be marked for deletion.',
126
+ });
127
+ }
128
+
129
+ if (snapshot.surface === 'live-app' && snapshot.lastPageUrl !== null && snapshot.pageUrl !== null && snapshot.pageUrl !== snapshot.lastPageUrl) {
130
+ nudges.push({
131
+ code: 'page_changed',
132
+ message: 'The human navigated to another page of the app since your last read; annotations are listed for the current page.',
133
+ path: snapshot.pageUrl,
134
+ });
135
+ }
136
+
137
+ const activeDocs = snapshot.otherDocuments
138
+ .filter((doc) => doc.newSinceLastRead > 0 || doc.composerOpen || doc.openedSinceLastRead)
139
+ .slice(0, MAX_OTHER_DOCUMENT_NUDGES);
140
+ for (const doc of activeDocs) {
141
+ nudges.push({
142
+ code: 'other_document_active',
143
+ message: doc.newSinceLastRead > 0
144
+ ? `The human is also annotating another document (${plural(doc.newSinceLastRead, 'new comment')}); read it by path.`
145
+ : doc.composerOpen
146
+ ? 'The human is typing a comment in another document; read it by path.'
147
+ : 'The human opened another document since your last read; read it by path.',
148
+ path: doc.path,
149
+ action: { tool: toolName('read_document'), args: { path: doc.path } },
150
+ });
151
+ }
152
+
153
+ if (snapshot.truncated) {
154
+ nudges.push({
155
+ code: 'truncated',
156
+ message: 'The text was windowed at a block boundary; continue from nextOffset to read the rest.',
157
+ action: { tool: toolName('read_document'), args: { ...snapshot.truncated.args, offset: snapshot.truncated.nextOffset } },
158
+ });
159
+ }
160
+
161
+ if (snapshot.decided) {
162
+ nudges.push({
163
+ code: 'session_decided',
164
+ message: 'The human has already decided this session; comment tools are no longer registered and nothing more can be added.',
165
+ });
166
+ } else if (snapshot.annotationCount > 0) {
167
+ nudges.push({
168
+ code: 'pending_unsent',
169
+ message: `${plural(snapshot.annotationCount, 'annotation')} ${snapshot.annotationCount === 1 ? 'is' : 'are'} pending; the human sends feedback and approves from the page, you do not.`,
170
+ });
171
+ }
172
+
173
+ return nudges;
174
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Host seam for the WebMCP provider, following the `utils/upload.ts` shape:
3
+ * a module-level default, `set`/`reset`/`get`, wired through
4
+ * `configurePlannotatorUI({ webmcp })`. Plannotator passes nothing and gets
5
+ * today's behavior: enabled (whenever `document.modelContext` exists) with the
6
+ * `plannotator.` name prefix.
7
+ */
8
+
9
+ export interface WebMcpPolicy {
10
+ /**
11
+ * Master switch. Default `true`; the engine still registers nothing when
12
+ * the browser has no `document.modelContext` or the user turned the tools
13
+ * off in Settings.
14
+ */
15
+ enabled?: boolean;
16
+ /** Prefix applied to every bare tool name. Default `"plannotator."`; hosts namespace their own tools. */
17
+ namePrefix?: string;
18
+ }
19
+
20
+ export interface ResolvedWebMcpPolicy {
21
+ enabled: boolean;
22
+ namePrefix: string;
23
+ }
24
+
25
+ export const DEFAULT_WEBMCP_NAME_PREFIX = 'plannotator.';
26
+
27
+ const defaultPolicy: ResolvedWebMcpPolicy = {
28
+ enabled: true,
29
+ namePrefix: DEFAULT_WEBMCP_NAME_PREFIX,
30
+ };
31
+
32
+ let policy: ResolvedWebMcpPolicy = defaultPolicy;
33
+
34
+ /** Override the provider policy. Call once at app startup. */
35
+ export function setWebMcpPolicy(next: WebMcpPolicy): void {
36
+ policy = {
37
+ enabled: next.enabled ?? defaultPolicy.enabled,
38
+ namePrefix: typeof next.namePrefix === 'string' ? next.namePrefix : defaultPolicy.namePrefix,
39
+ };
40
+ }
41
+
42
+ /** Reset to Plannotator's default policy. Mainly for tests. */
43
+ export function resetWebMcpPolicy(): void {
44
+ policy = defaultPolicy;
45
+ }
46
+
47
+ /** Read the active policy at call time (so a late override is honored). */
48
+ export function getWebMcpPolicy(): ResolvedWebMcpPolicy {
49
+ return policy;
50
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The user's WebMCP opt-out, kept OUT of the settings registry on purpose.
3
+ *
4
+ * `configStore.ensureLoaded` seeds every registry entry's default into a
5
+ * cookie on first settings access, which would write a cookie for every
6
+ * user of every surface (plan, annotate, code review, the guides.show
7
+ * viewer) whether or not their browser has `document.modelContext`. That is
8
+ * a footprint. This module is idle at its default: nothing is read or
9
+ * written until the Settings row (shown only when the API exists) is
10
+ * toggled, and turning the tools back on removes the cookie instead of
11
+ * writing `true`.
12
+ *
13
+ * Cookie: `plannotator-webmcp-tools=false` while opted out; absent otherwise.
14
+ */
15
+ import { useSyncExternalStore } from 'react';
16
+ import { storage } from '../utils/storage';
17
+
18
+ export const WEBMCP_TOOLS_COOKIE = 'plannotator-webmcp-tools';
19
+
20
+ const listeners = new Set<() => void>();
21
+ /** Resolved lazily on first read; write-through afterwards so the snapshot is stable for useSyncExternalStore. */
22
+ let cached: boolean | null = null;
23
+
24
+ /** Whether the tools are enabled for this browser (default true; only an explicit opt-out disables). */
25
+ export function getWebMcpToolsEnabled(): boolean {
26
+ if (cached === null) cached = storage.getItem(WEBMCP_TOOLS_COOKIE) !== 'false';
27
+ return cached;
28
+ }
29
+
30
+ /** Persist the opt-out. `true` removes the cookie so the default stays cookie-free. */
31
+ export function setWebMcpToolsEnabled(enabled: boolean): void {
32
+ if (enabled) {
33
+ if (storage.getItem(WEBMCP_TOOLS_COOKIE) !== null) storage.removeItem(WEBMCP_TOOLS_COOKIE);
34
+ } else {
35
+ storage.setItem(WEBMCP_TOOLS_COOKIE, 'false');
36
+ }
37
+ cached = enabled;
38
+ for (const listener of listeners) listener();
39
+ }
40
+
41
+ export function subscribeWebMcpToolsEnabled(listener: () => void): () => void {
42
+ listeners.add(listener);
43
+ return () => {
44
+ listeners.delete(listener);
45
+ };
46
+ }
47
+
48
+ export function useWebMcpToolsEnabled(): boolean {
49
+ return useSyncExternalStore(subscribeWebMcpToolsEnabled, getWebMcpToolsEnabled, getWebMcpToolsEnabled);
50
+ }