@plannotator/ui 0.30.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 (102) hide show
  1. package/README.md +46 -1
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Minimal JSON Schema checker for the shapes the tool catalogs declare:
3
+ * `object` (properties / required / additionalProperties), `string`
4
+ * (minLength / maxLength / enum), `integer` / `number` (minimum / maximum),
5
+ * `boolean`, `array` (items / minItems / maxItems). Anything else passes.
6
+ *
7
+ * Pure and dependency-free so it can move to `@plannotator/core` untouched.
8
+ * Returns the first problem as one sentence for the model, or `null`.
9
+ */
10
+
11
+ export type JsonSchema = Record<string, unknown>;
12
+
13
+ export function validateAgainstSchema(schema: JsonSchema | undefined, value: unknown, path = 'input'): string | null {
14
+ if (!schema) return null;
15
+ const type = schema.type;
16
+ const enumValues = schema.enum;
17
+ if (Array.isArray(enumValues) && !enumValues.some((v) => v === value)) {
18
+ return `${path} must be one of ${enumValues.map((v) => JSON.stringify(v)).join(', ')}`;
19
+ }
20
+ switch (type) {
21
+ case 'object': {
22
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return `${path} must be an object`;
23
+ const record = value as Record<string, unknown>;
24
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
25
+ const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
26
+ for (const key of required) {
27
+ if (record[key] === undefined) return `${path}.${key} is required`;
28
+ }
29
+ for (const [key, child] of Object.entries(record)) {
30
+ const childSchema = properties[key];
31
+ if (!childSchema) {
32
+ if (schema.additionalProperties === false) return `${path}.${key} is not a known field`;
33
+ continue;
34
+ }
35
+ if (child === undefined) continue;
36
+ const problem = validateAgainstSchema(childSchema, child, `${path}.${key}`);
37
+ if (problem) return problem;
38
+ }
39
+ return null;
40
+ }
41
+ case 'string': {
42
+ if (typeof value !== 'string') return `${path} must be a string`;
43
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
44
+ return `${path} must be at least ${schema.minLength} characters`;
45
+ }
46
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
47
+ return `${path} must be at most ${schema.maxLength} characters`;
48
+ }
49
+ return null;
50
+ }
51
+ case 'integer':
52
+ case 'number': {
53
+ if (typeof value !== 'number' || !Number.isFinite(value)) return `${path} must be a number`;
54
+ if (type === 'integer' && !Number.isInteger(value)) return `${path} must be an integer`;
55
+ if (typeof schema.minimum === 'number' && value < schema.minimum) return `${path} must be >= ${schema.minimum}`;
56
+ if (typeof schema.maximum === 'number' && value > schema.maximum) return `${path} must be <= ${schema.maximum}`;
57
+ return null;
58
+ }
59
+ case 'boolean':
60
+ return typeof value === 'boolean' ? null : `${path} must be a boolean`;
61
+ case 'array': {
62
+ if (!Array.isArray(value)) return `${path} must be an array`;
63
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
64
+ return `${path} must have at least ${schema.minItems} item${schema.minItems === 1 ? '' : 's'}`;
65
+ }
66
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
67
+ return `${path} must have at most ${schema.maxItems} items`;
68
+ }
69
+ const items = schema.items as JsonSchema | undefined;
70
+ if (items) {
71
+ for (let i = 0; i < value.length; i++) {
72
+ const problem = validateAgainstSchema(items, value[i], `${path}[${i}]`);
73
+ if (problem) return problem;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ default:
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Tool definitions, the response envelope, the execute wrapper, and the
3
+ * per-document registry.
4
+ *
5
+ * Spec-agnostic: the only WebMCP names live in ./modelContext.ts. Everything
6
+ * here works against `ModelContextLike`, so a fake can be injected in tests
7
+ * and the whole engine runs under plain `bun test`.
8
+ */
9
+
10
+ import {
11
+ TOOL_NAME_PATTERN,
12
+ type ModelContextExecuteContext,
13
+ type ModelContextLike,
14
+ type ModelContextToolAnnotations,
15
+ type ModelContextToolDescriptor,
16
+ } from './modelContext';
17
+ import { validateAgainstSchema, type JsonSchema } from './schema';
18
+ import { recordToolCall } from './activity';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Envelope
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type ToolErrorCode =
25
+ | 'invalid_input'
26
+ | 'not_found'
27
+ | 'ambiguous'
28
+ | 'forbidden'
29
+ | 'not_available'
30
+ | 'conflict'
31
+ | 'failed';
32
+
33
+ export interface ToolError {
34
+ code: ToolErrorCode;
35
+ message: string;
36
+ hint?: string;
37
+ /** For `ambiguous`: the contexts the caller can choose between. */
38
+ candidates?: string[];
39
+ }
40
+
41
+ export type NudgeCode =
42
+ | 'annotations_new'
43
+ | 'annotations_removed'
44
+ | 'replies_new'
45
+ | 'composer_open'
46
+ | 'source_stale'
47
+ | 'document_edited'
48
+ | 'comment_only_surface'
49
+ | 'page_changed'
50
+ | 'other_document_active'
51
+ | 'pending_unsent'
52
+ | 'session_decided'
53
+ | 'truncated';
54
+
55
+ export interface Nudge {
56
+ /** Machine-readable, stable. */
57
+ code: NudgeCode;
58
+ /** One sentence for the model. Static text we own; never document or comment text. */
59
+ message: string;
60
+ /** Annotation ids this is about. */
61
+ ids?: string[];
62
+ /** Document this is about (folder / linked-doc sessions) or page (live app). */
63
+ path?: string;
64
+ /** Heading slug this is about. */
65
+ section?: string;
66
+ /** The one call that acts on it. */
67
+ action?: { tool: string; args: Record<string, unknown> };
68
+ }
69
+
70
+ /** What a handler returns; the engine appends nudges. */
71
+ export type ToolResult<T> =
72
+ | { ok: true; data: T; cursor?: string }
73
+ | { ok: false; error: ToolError };
74
+
75
+ /** What the browser agent receives, identical for every tool. */
76
+ export type ToolResponse<T = unknown> =
77
+ | { ok: true; data: T; nudges: Nudge[]; cursor?: string }
78
+ | { ok: false; error: ToolError; nudges: Nudge[] };
79
+
80
+ export function ok<T>(data: T, cursor?: string): ToolResult<T> {
81
+ return cursor === undefined ? { ok: true, data } : { ok: true, data, cursor };
82
+ }
83
+
84
+ export function fail(code: ToolErrorCode, message: string, extra?: { hint?: string; candidates?: string[] }): ToolResult<never> {
85
+ const error: ToolError = { code, message };
86
+ if (extra?.hint) error.hint = extra.hint;
87
+ if (extra?.candidates) error.candidates = extra.candidates;
88
+ return { ok: false, error };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Tool specs
93
+ // ---------------------------------------------------------------------------
94
+
95
+ export interface ToolSpec<I = unknown, T = unknown> {
96
+ /** Bare name; the registry applies the policy prefix. */
97
+ name: string;
98
+ title?: string;
99
+ description: string;
100
+ inputSchema?: JsonSchema;
101
+ annotations?: ModelContextToolAnnotations;
102
+ execute: (input: I, context: ModelContextExecuteContext) => ToolResult<T> | Promise<ToolResult<T>>;
103
+ }
104
+
105
+ /** Spec-level limits the catalog tests pin. */
106
+ export const TOOL_DESCRIPTION_MAX_CHARS = 500;
107
+ export const TOOL_PARAM_DESCRIPTION_MAX_CHARS = 150;
108
+
109
+ /**
110
+ * Identity function with definition-time validation. A bad name or an empty
111
+ * description is a programming error, so it throws here (never at register
112
+ * time, where the browser would reject silently).
113
+ */
114
+ export function defineTool<I, T>(spec: ToolSpec<I, T>): ToolSpec<I, T> {
115
+ if (!TOOL_NAME_PATTERN.test(spec.name)) {
116
+ throw new Error(`webmcp: invalid tool name "${spec.name}"`);
117
+ }
118
+ if (!spec.description || !spec.description.trim()) {
119
+ throw new Error(`webmcp: tool "${spec.name}" needs a description`);
120
+ }
121
+ return spec;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Execute wrapper
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export interface ToolsetHooks {
129
+ /** Runs AFTER the handler, so a mutation's nudges reflect the mutation. */
130
+ buildNudges: (info: { tool: string; result: ToolResult<unknown> }) => Nudge[];
131
+ /** Runs after the envelope is built (the watermark advances here). */
132
+ afterResponse?: (response: ToolResponse) => void;
133
+ }
134
+
135
+ function describeError(error: unknown): string {
136
+ if (error instanceof Error) return error.message || error.name;
137
+ if (typeof error === 'string') return error;
138
+ return 'unexpected failure';
139
+ }
140
+
141
+ /**
142
+ * Run one tool call end to end: validate the input against the declared
143
+ * schema, invoke the handler, coerce the result into the envelope, attach
144
+ * nudges (error responses included), notify hooks, and record activity.
145
+ *
146
+ * The promise NEVER rejects and the value is always JSON-serializable: a
147
+ * rejection or an unserializable value would reach the agent as a bare
148
+ * `UnknownError` with no message.
149
+ */
150
+ export async function runTool(
151
+ spec: ToolSpec<unknown, unknown>,
152
+ hooks: ToolsetHooks,
153
+ rawInput: unknown,
154
+ context: ModelContextExecuteContext,
155
+ ): Promise<ToolResponse> {
156
+ let result: ToolResult<unknown>;
157
+ try {
158
+ const input = rawInput === undefined || rawInput === null ? {} : rawInput;
159
+ const problem = validateAgainstSchema(spec.inputSchema, input);
160
+ if (problem) {
161
+ result = fail('invalid_input', problem);
162
+ } else {
163
+ const returned = await spec.execute(input, context);
164
+ result =
165
+ returned && typeof returned === 'object' && typeof (returned as ToolResult<unknown>).ok === 'boolean'
166
+ ? returned
167
+ : fail('failed', 'the tool produced no result');
168
+ }
169
+ } catch (error) {
170
+ result = fail('failed', describeError(error));
171
+ }
172
+
173
+ let nudges: Nudge[] = [];
174
+ try {
175
+ nudges = hooks.buildNudges({ tool: spec.name, result }) ?? [];
176
+ } catch {
177
+ nudges = [];
178
+ }
179
+
180
+ // Equality (not truthiness) narrowing: the ui tsconfig runs without
181
+ // strictNullChecks, where `if (result.ok)` does not narrow the union.
182
+ let response: ToolResponse;
183
+ if (result.ok === false) {
184
+ response = { ok: false, error: result.error, nudges };
185
+ } else {
186
+ response = { ok: true, data: result.data === undefined ? null : result.data, nudges };
187
+ if (result.cursor !== undefined) response.cursor = result.cursor;
188
+ }
189
+
190
+ try {
191
+ // Strip `undefined` members and prove serializability in one pass.
192
+ response = JSON.parse(JSON.stringify(response)) as ToolResponse;
193
+ } catch (error) {
194
+ response = { ok: false, error: { code: 'failed', message: `unserializable result: ${describeError(error)}` }, nudges: [] };
195
+ }
196
+
197
+ try {
198
+ hooks.afterResponse?.(response);
199
+ } catch {
200
+ // Hook failures never reach the agent.
201
+ }
202
+ if (response.ok) recordToolCall(spec.name);
203
+ return response;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Registry
208
+ // ---------------------------------------------------------------------------
209
+
210
+ export interface ToolRegistry {
211
+ /**
212
+ * Register the set's tools (reconciled by prefixed name) and return a
213
+ * detach function that aborts every controller the set owns. Re-attaching
214
+ * the same set with the same names and unchanged descriptors only swaps the
215
+ * handlers in place, so a React re-render never touches `registerTool`.
216
+ */
217
+ attach(setId: string, tools: ToolSpec<never, unknown>[], hooks: ToolsetHooks): () => void;
218
+ /** Prefixed names currently registered through this registry. */
219
+ names(): string[];
220
+ }
221
+
222
+ interface LiveEntry {
223
+ setId: string;
224
+ controller: AbortController;
225
+ signature: string;
226
+ current: { spec: ToolSpec<unknown, unknown>; hooks: ToolsetHooks };
227
+ }
228
+
229
+ function signatureOf(spec: ToolSpec<unknown, unknown>): string {
230
+ return JSON.stringify([spec.title ?? null, spec.description, spec.inputSchema ?? null, spec.annotations ?? null]);
231
+ }
232
+
233
+ export interface CreateToolRegistryOptions {
234
+ /** Read at attach time so a late policy override is honored. */
235
+ prefix: () => string;
236
+ warn?: (message: string) => void;
237
+ }
238
+
239
+ export function createToolRegistry(ctx: ModelContextLike, options: CreateToolRegistryOptions): ToolRegistry {
240
+ const live = new Map<string, LiveEntry>();
241
+ const warnedNames = new Set<string>();
242
+ const warn = options.warn ?? ((message: string) => console.warn(message));
243
+
244
+ const warnOnce = (name: string, message: string) => {
245
+ if (warnedNames.has(name)) return;
246
+ warnedNames.add(name);
247
+ warn(message);
248
+ };
249
+
250
+ const register = (name: string, setId: string, spec: ToolSpec<unknown, unknown>, hooks: ToolsetHooks) => {
251
+ const controller = new AbortController();
252
+ const entry: LiveEntry = { setId, controller, signature: signatureOf(spec), current: { spec, hooks } };
253
+ live.set(name, entry);
254
+ const descriptor: ModelContextToolDescriptor = {
255
+ name,
256
+ description: spec.description,
257
+ ...(spec.title ? { title: spec.title } : {}),
258
+ ...(spec.inputSchema ? { inputSchema: spec.inputSchema } : {}),
259
+ ...(spec.annotations ? { annotations: spec.annotations } : {}),
260
+ execute: (input, context) => runTool(entry.current.spec, entry.current.hooks, input, context),
261
+ };
262
+ let registration: Promise<unknown>;
263
+ try {
264
+ registration = Promise.resolve(ctx.registerTool(descriptor, { signal: controller.signal }));
265
+ } catch (error) {
266
+ registration = Promise.reject(error);
267
+ }
268
+ registration.catch((error: unknown) => {
269
+ // An abort rejects the registration promise by design; only a genuine
270
+ // registration failure is worth one line.
271
+ if (controller.signal.aborted) return;
272
+ warnOnce(name, `webmcp: could not register "${name}": ${describeError(error)}`);
273
+ });
274
+ };
275
+
276
+ const unregister = (name: string) => {
277
+ const entry = live.get(name);
278
+ if (!entry) return;
279
+ live.delete(name);
280
+ entry.controller.abort();
281
+ };
282
+
283
+ return {
284
+ attach(setId, tools, hooks) {
285
+ const prefix = options.prefix();
286
+ const wanted = new Set<string>();
287
+ for (const bare of tools) {
288
+ const spec = bare as ToolSpec<unknown, unknown>;
289
+ const name = `${prefix}${spec.name}`;
290
+ if (!TOOL_NAME_PATTERN.test(name)) {
291
+ warnOnce(name, `webmcp: skipping tool with invalid name "${name}"`);
292
+ continue;
293
+ }
294
+ wanted.add(name);
295
+ const existing = live.get(name);
296
+ if (existing && existing.setId !== setId) {
297
+ warnOnce(name, `webmcp: tool "${name}" is already registered by "${existing.setId}"; skipping the copy from "${setId}"`);
298
+ continue;
299
+ }
300
+ if (existing) {
301
+ if (existing.signature === signatureOf(spec)) {
302
+ existing.current = { spec, hooks };
303
+ continue;
304
+ }
305
+ unregister(name);
306
+ }
307
+ register(name, setId, spec, hooks);
308
+ }
309
+ for (const [name, entry] of live) {
310
+ if (entry.setId === setId && !wanted.has(name)) unregister(name);
311
+ }
312
+ return () => {
313
+ for (const [name, entry] of live) {
314
+ if (entry.setId === setId) unregister(name);
315
+ }
316
+ };
317
+ },
318
+ names() {
319
+ return [...live.keys()].sort();
320
+ },
321
+ };
322
+ }
323
+
324
+ const registries = new WeakMap<object, ToolRegistry>();
325
+
326
+ /**
327
+ * One registry per ModelContext (which is one per Document): registration is
328
+ * document-scoped and the name space is flat.
329
+ */
330
+ export function getRegistryFor(ctx: ModelContextLike, options: CreateToolRegistryOptions): ToolRegistry {
331
+ let registry = registries.get(ctx);
332
+ if (!registry) {
333
+ registry = createToolRegistry(ctx, options);
334
+ registries.set(ctx, registry);
335
+ }
336
+ return registry;
337
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * React hook: attach a named toolset to the document's registry.
3
+ *
4
+ * Zero footprint without WebMCP: `resolveModelContext` runs once per mount
5
+ * and, when it returns null, the hook builds nothing, registers nothing and
6
+ * owns no effect body. Handlers read through refs, so a re-render never
7
+ * touches `registerTool`; only `deps` (surface membership inputs) rebuild the
8
+ * tool list, and the registry reconciles by name so an unchanged descriptor
9
+ * merely swaps its handler in place.
10
+ */
11
+ import { useEffect, useMemo, useRef } from 'react';
12
+ import { resolveModelContext, type ModelContextLike } from './modelContext';
13
+ import { getWebMcpPolicy } from './policy';
14
+ import { getRegistryFor, type ToolSpec, type ToolsetHooks } from './toolset';
15
+
16
+ export interface UseToolsetOptions {
17
+ /** Stable set id (one per surface). */
18
+ id: string;
19
+ /** Registers while true; false aborts every controller of the set. Default true. */
20
+ active?: boolean;
21
+ /** Builds the tool list; only invoked when a provider exists and the set is active. */
22
+ build: () => ToolSpec<never, unknown>[];
23
+ /** Inputs whose change should rebuild the list (surface, read-only, submitted, folder). */
24
+ deps: ReadonlyArray<unknown>;
25
+ hooks: ToolsetHooks;
26
+ /** Test seam: inject a fake ModelContext instead of resolving `document`. */
27
+ context?: ModelContextLike | null;
28
+ }
29
+
30
+ export interface UseToolsetResult {
31
+ /** `document.modelContext` exists in this browser. */
32
+ available: boolean;
33
+ /** Tools are currently attached. */
34
+ registered: boolean;
35
+ }
36
+
37
+ export function useToolset(options: UseToolsetOptions): UseToolsetResult {
38
+ const { id, active = true, build, deps, hooks, context } = options;
39
+ // One detection per mount; the entry point is created with the document
40
+ // and never appears later.
41
+ const ctx = useMemo(() => (context === undefined ? resolveModelContext() : context), [context]);
42
+ const enabled = !!ctx && getWebMcpPolicy().enabled && active;
43
+
44
+ const hooksRef = useRef(hooks);
45
+ hooksRef.current = hooks;
46
+ const stableHooks = useMemo<ToolsetHooks>(() => ({
47
+ buildNudges: (info) => hooksRef.current.buildNudges(info),
48
+ afterResponse: (response) => hooksRef.current.afterResponse?.(response),
49
+ }), []);
50
+
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ const tools = useMemo(() => (enabled ? build() : null), [enabled, ...deps]);
53
+
54
+ // Two effects on purpose: a rebuilt tool list reconciles IN PLACE (the
55
+ // registry swaps handlers for unchanged descriptors and only aborts names
56
+ // that disappeared), while unmount and `active: false` are the only paths
57
+ // that detach the whole set. A single effect keyed on `tools` would detach
58
+ // and re-register every tool on each rebuild.
59
+ const detachRef = useRef<(() => void) | null>(null);
60
+ useEffect(() => {
61
+ if (!ctx || !enabled || !tools) return;
62
+ const registry = getRegistryFor(ctx, { prefix: () => getWebMcpPolicy().namePrefix });
63
+ detachRef.current = registry.attach(id, tools, stableHooks);
64
+ }, [ctx, enabled, tools, id, stableHooks]);
65
+ useEffect(() => {
66
+ if (!ctx || !enabled) return;
67
+ return () => {
68
+ detachRef.current?.();
69
+ detachRef.current = null;
70
+ };
71
+ }, [ctx, enabled, id]);
72
+
73
+ return { available: !!ctx, registered: enabled && !!tools };
74
+ }
@@ -1,187 +0,0 @@
1
- import React from 'react';
2
- import { createPortal } from 'react-dom';
3
- import type { Origin } from '@plannotator/core/agents';
4
- import { AGENT_CONFIG, getAgentAIProviderTypes, getAgentName } from '@plannotator/core/agents';
5
- import { SparklesIcon } from './SparklesIcon';
6
- import { getProviderMeta } from './ProviderIcons';
7
-
8
- interface PlanAIAnnouncementProvider {
9
- id: string;
10
- name: string;
11
- }
12
-
13
- interface PlanAIAnnouncementDialogProps {
14
- isOpen: boolean;
15
- origin?: Origin | null;
16
- providerName?: string | null;
17
- /** Actually-detected providers (installed + authenticated). Cards matching one of these are selectable. */
18
- providers?: PlanAIAnnouncementProvider[];
19
- onSelectProvider?: (providerId: string) => void;
20
- onOpenAI: () => void;
21
- onDismiss: () => void;
22
- }
23
-
24
- const SUPPORTED_AI_PROVIDER_TYPES = Array.from(
25
- new Set(
26
- (Object.keys(AGENT_CONFIG) as Origin[])
27
- .flatMap(agentOrigin => getAgentAIProviderTypes(agentOrigin))
28
- )
29
- );
30
-
31
- const UNSUPPORTED_AI_ORIGINS = (Object.keys(AGENT_CONFIG) as Origin[])
32
- .filter(agentOrigin => getAgentAIProviderTypes(agentOrigin).length === 0);
33
-
34
- export const PlanAIAnnouncementDialog: React.FC<PlanAIAnnouncementDialogProps> = ({
35
- isOpen,
36
- origin,
37
- providerName,
38
- providers = [],
39
- onSelectProvider,
40
- onOpenAI,
41
- onDismiss,
42
- }) => {
43
- if (!isOpen) return null;
44
-
45
- const agentLabel = getAgentName(origin ?? undefined);
46
- const providerLabel = providerName ? getProviderMeta(providerName).label : null;
47
- const unsupportedLabels = UNSUPPORTED_AI_ORIGINS.map(getAgentName).join(', ');
48
-
49
- return createPortal(
50
- <div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-sm p-4">
51
- <div className="bg-card border border-border rounded-xl w-full max-w-xl shadow-2xl">
52
- {/* Header */}
53
- <div className="p-5 border-b border-border">
54
- <div className="flex items-center gap-2 mb-2">
55
- <div className="p-1.5 rounded-lg bg-primary/15">
56
- <SparklesIcon className="w-5 h-5 text-primary" />
57
- </div>
58
- <h3 className="font-semibold text-base">New: Ask AI for annotated documents</h3>
59
- </div>
60
- <p className="text-sm text-muted-foreground">
61
- Chat with plans and annotated documents directly inside Plannotator.
62
- </p>
63
- {providerLabel && (
64
- <p className="text-xs text-muted-foreground/70 mt-1">
65
- Plannotator selected {providerLabel} for {agentLabel}.
66
- </p>
67
- )}
68
- </div>
69
-
70
- {/* Details */}
71
- <div className="p-4 space-y-4">
72
- {SUPPORTED_AI_PROVIDER_TYPES.length > 0 && (
73
- <div>
74
- <div className="text-xs text-muted-foreground mb-2">Supported providers</div>
75
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
76
- {SUPPORTED_AI_PROVIDER_TYPES.map(providerType => {
77
- const meta = getProviderMeta(providerType);
78
- const Icon = meta.icon;
79
- const isSelected = providerType === providerName;
80
- // A card is selectable only if the provider is actually detected on this machine.
81
- const detected = providers.find(p => p.name === providerType) ?? null;
82
- const isSelectable = Boolean(detected && onSelectProvider);
83
-
84
- const inner = (
85
- <>
86
- <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-background/70 text-muted-foreground">
87
- <Icon className="h-[18px] w-[18px]" />
88
- </div>
89
- <div className="min-w-0">
90
- <div className="truncate text-sm font-medium">{meta.label}</div>
91
- {isSelected ? (
92
- <div className="text-[10px] uppercase tracking-wide text-primary">selected</div>
93
- ) : !detected ? (
94
- <div className="text-[10px] text-muted-foreground/60">not installed</div>
95
- ) : null}
96
- </div>
97
- </>
98
- );
99
-
100
- if (!isSelectable) {
101
- return (
102
- <div
103
- key={providerType}
104
- className={`flex min-h-16 items-center gap-2 rounded-lg border p-2.5 ${
105
- isSelected
106
- ? 'border-primary bg-primary/5'
107
- : 'border-border bg-muted/35 opacity-60'
108
- }`}
109
- >
110
- {inner}
111
- </div>
112
- );
113
- }
114
-
115
- return (
116
- <button
117
- key={providerType}
118
- type="button"
119
- onClick={() => onSelectProvider?.(detected!.id)}
120
- aria-pressed={isSelected}
121
- className={`flex min-h-16 items-center gap-2 rounded-lg border p-2.5 text-left transition-colors ${
122
- isSelected
123
- ? 'border-primary bg-primary/5'
124
- : 'border-border bg-muted/35 hover:border-muted-foreground/30 hover:bg-muted/60'
125
- }`}
126
- >
127
- {inner}
128
- </button>
129
- );
130
- })}
131
- </div>
132
- </div>
133
- )}
134
-
135
- <div className="grid gap-2 sm:grid-cols-2">
136
- <div className="flex items-start gap-3 p-3 rounded-lg border border-transparent bg-muted/50">
137
- <SparklesIcon className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
138
- <div className="flex-1">
139
- <div className="text-sm font-medium">Ask from comments</div>
140
- <div className="text-xs text-muted-foreground">
141
- Use Ask AI from a comment popover to chat about selected text.
142
- </div>
143
- </div>
144
- </div>
145
- <div className="flex items-start gap-3 p-3 rounded-lg border border-transparent bg-muted/50">
146
- <SparklesIcon className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
147
- <div className="flex-1">
148
- <div className="text-sm font-medium">Open the side chat</div>
149
- <div className="text-xs text-muted-foreground">
150
- Use the AI button in the header for broader document questions.
151
- </div>
152
- </div>
153
- </div>
154
- </div>
155
-
156
- {unsupportedLabels && (
157
- <p className="text-[11px] leading-relaxed text-muted-foreground/70">
158
- Not supported for Ask AI yet: {unsupportedLabels}.
159
- </p>
160
- )}
161
- </div>
162
-
163
- {/* Footer */}
164
- <div className="p-4 border-t border-border flex justify-between items-center gap-3">
165
- <p className="text-xs text-muted-foreground">
166
- This notice only appears once.
167
- </p>
168
- <div className="flex items-center gap-2">
169
- <button
170
- onClick={onDismiss}
171
- className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
172
- >
173
- Dismiss
174
- </button>
175
- <button
176
- onClick={onOpenAI}
177
- className="px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
178
- >
179
- Open AI Chat
180
- </button>
181
- </div>
182
- </div>
183
- </div>
184
- </div>,
185
- document.body
186
- );
187
- };