@plannotator/ui 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/HANDOFF.md +664 -0
  2. package/README.md +57 -1
  3. package/components/AnnotationPanel.tsx +96 -4
  4. package/components/AnnotationToolbar.tsx +25 -25
  5. package/components/CommentPopover.tsx +26 -0
  6. package/components/GraphvizBlock.tsx +86 -7
  7. package/components/HtmlSurfaceControls.tsx +170 -0
  8. package/components/InlineMarkdown.tsx +22 -2
  9. package/components/MermaidBlock.tsx +60 -26
  10. package/components/Settings.tsx +40 -1
  11. package/components/blocks/MathBlock.tsx +26 -14
  12. package/components/html-viewer/HtmlViewer.tsx +289 -6
  13. package/components/html-viewer/bridge-script.asset.js +4392 -0
  14. package/components/html-viewer/bridge-script.lite.ts +9 -0
  15. package/components/html-viewer/bridge-script.ts +54 -8
  16. package/components/html-viewer/hostThreads.ts +37 -0
  17. package/components/html-viewer/index.ts +22 -1
  18. package/components/html-viewer/srcdoc.ts +70 -1
  19. package/components/html-viewer/unanchored.ts +47 -0
  20. package/components/html-viewer/useHtmlAnnotation.ts +132 -5
  21. package/configure.ts +32 -0
  22. package/hooks/useHtmlRefresh.ts +149 -0
  23. package/hooks/useMathRenderer.ts +30 -0
  24. package/hooks/useSharing.ts +31 -5
  25. package/package.json +9 -3
  26. package/styles.css +1 -1
  27. package/types.ts +1 -0
  28. package/utils/generateIdentity.ts +64 -14
  29. package/utils/identity-tater.ts +36 -0
  30. package/utils/math-default-loader.ts +24 -0
  31. package/utils/math-eager.ts +25 -0
  32. package/utils/math.ts +149 -0
  33. package/utils/mermaid-eager.ts +28 -0
  34. package/utils/mermaid.ts +132 -0
  35. package/utils/parser.ts +38 -0
  36. package/utils/quickLabels.ts +13 -0
  37. package/webmcp/activity.ts +46 -0
  38. package/webmcp/changes.ts +227 -0
  39. package/webmcp/index.ts +72 -0
  40. package/webmcp/modelContext.ts +103 -0
  41. package/webmcp/nudges.ts +174 -0
  42. package/webmcp/policy.ts +50 -0
  43. package/webmcp/preference.ts +50 -0
  44. package/webmcp/schema.ts +81 -0
  45. package/webmcp/toolset.ts +337 -0
  46. package/webmcp/useToolset.ts +74 -0
@@ -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,8 +38,11 @@ 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,
44
+ checkBridgeProtocolVersion,
45
+ formatBridgeProtocolWarning,
42
46
  rejectsLiveMessage,
43
47
  useHtmlAnnotation,
44
48
  type HtmlLiveSession,
@@ -49,6 +53,7 @@ import {
49
53
  buildThemeTokenPayload,
50
54
  hasHostThemeOptIn,
51
55
  injectIntoHead,
56
+ resolveBridgeScriptUrl,
52
57
  } from "./srcdoc";
53
58
 
54
59
  const PREFIX = "plannotator-bridge-";
@@ -129,6 +134,38 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
129
134
 
130
135
  const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
131
136
 
137
+ /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
138
+ export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
139
+
140
+ /**
141
+ * Why the bridge could not be established on the `bridgeScriptUrl` path.
142
+ * Never produced on the inline path (the bridge and the parent are one
143
+ * bundle there, and no ready timer runs).
144
+ */
145
+ export type BridgeUnavailableInfo =
146
+ | {
147
+ kind: "timeout";
148
+ url: string;
149
+ /** The wait that elapsed without a `ready`. */
150
+ timeoutMs: number;
151
+ }
152
+ | {
153
+ kind: "version-mismatch";
154
+ url: string;
155
+ expectedVersion: number;
156
+ /** Absent when the ready carried no stamp (a pre-stamp asset). */
157
+ reportedVersion?: number;
158
+ };
159
+
160
+ /** User-facing message for the in-surface error banner. */
161
+ export function formatBridgeUnavailableMessage(info: BridgeUnavailableInfo): string {
162
+ if (info.kind === "timeout") {
163
+ return `Annotation tools did not load: the bridge script at ${info.url} sent no ready signal within ${info.timeoutMs} ms. The page is shown without annotation. Check that the URL is reachable and that your Content Security Policy allows script-src for that origin.`;
164
+ }
165
+ const reported = info.reportedVersion === undefined ? "no version" : `version ${info.reportedVersion}`;
166
+ return `Annotation tools may not work: this viewer expects bridge protocol version ${info.expectedVersion}, but the script at ${info.url} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
167
+ }
168
+
132
169
  function parseVimBridgeCopy(value: unknown): string | null {
133
170
  return isRecord(value)
134
171
  && value.type === `${PREFIX}vim-copy`
@@ -202,10 +239,51 @@ export interface HtmlViewerProps {
202
239
  /** Reports the full set of annotation ids with no live representation on
203
240
  * the page (fail-closed anchors hide markers rather than guess). Called
204
241
  * with the complete current set whenever it changes, including back to
205
- * empty on recovery. Fires in readOnly mode too. */
242
+ * empty on recovery. Fires in readOnly mode too. Complete over the
243
+ * `annotations` prop: page rows with nothing to restore by (no quoted
244
+ * text, no element anchor) are reported even though the bridge never
245
+ * sees them, and an id this viewer minted for a local comment that the
246
+ * host swapped out of `annotations` for its own id is not reported. */
206
247
  onUnanchoredChange?: (ids: string[]) => void;
248
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
249
+ * Enforced at the trust boundary, on submit and on restore, and carried
250
+ * to the bridge so the in-page toggle stops at the same number. Default
251
+ * 16 (the package cap); absent leaves every message unchanged. */
252
+ maxAdditionalTargets?: number;
253
+ /** scrollIntoView behavior when a selected annotation is scrolled into
254
+ * view inside the page. Default 'smooth'; pass 'auto' to carry the
255
+ * parent's reduced-motion preference across the iframe boundary. */
256
+ scrollBehavior?: 'smooth' | 'auto';
207
257
  /** Accessible iframe title. */
208
258
  title?: string;
259
+ /**
260
+ * Opt-in: load the annotation bridge into the srcdoc document through a
261
+ * classic `<script src>` from this URL (the package's generated
262
+ * `components/html-viewer/bridge-script.asset.js`, served by the host)
263
+ * instead of inlining the 185 KB script into every document. Absent (the
264
+ * default, and Plannotator's only path): inline, unchanged. The tag lands
265
+ * where the inline script does, at the end of `<head>`, before the body.
266
+ * The URL is resolved against THIS document's base (`document.baseURI`)
267
+ * before it is written, never against the framed page, so a page's own
268
+ * `<base href>` cannot redirect it. The srcdoc frame is an opaque origin,
269
+ * so the script needs no CORS and no `crossorigin` attribute is set; a CSP
270
+ * header on the host page is inherited by the frame and must allow
271
+ * `script-src` for the asset origin, and the asset must not be served with
272
+ * `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
273
+ * mode, where the proxy injects the bridge.
274
+ */
275
+ bridgeScriptUrl?: string;
276
+ /**
277
+ * How long to wait for the bridge's `ready` after each document load on
278
+ * the `bridgeScriptUrl` path before the surface shows an error state.
279
+ * Default 5000 ms. No timer runs on the inline path.
280
+ */
281
+ bridgeReadyTimeoutMs?: number;
282
+ /** The bridge could not be established on the `bridgeScriptUrl` path (no
283
+ * ready within the timeout, or a protocol version mismatch). The surface
284
+ * shows its own banner as well; this lets the host react (telemetry, a
285
+ * retry affordance). Never called on the inline path. */
286
+ onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
209
287
  }
210
288
 
211
289
  /**
@@ -245,7 +323,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
245
323
  onAskAI,
246
324
  readOnly = false,
247
325
  onUnanchoredChange,
326
+ maxAdditionalTargets,
327
+ scrollBehavior,
248
328
  title = "HTML Plan Viewer",
329
+ bridgeScriptUrl,
330
+ bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
331
+ onBridgeUnavailable,
249
332
  },
250
333
  ref,
251
334
  ) => {
@@ -303,6 +386,18 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
303
386
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
304
387
  const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
305
388
 
389
+ // The URL path is srcdoc-only: live mode has the proxy inject the bridge.
390
+ // Resolved against THIS document's base before it is written into the
391
+ // srcdoc, so a framed page's own <base href> can never re-anchor it.
392
+ const bridgeUrl = useMemo(
393
+ () => (!liveMode && bridgeScriptUrl
394
+ ? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
395
+ : undefined),
396
+ [liveMode, bridgeScriptUrl],
397
+ );
398
+ const bridgeUrlRef = useRef(bridgeUrl);
399
+ bridgeUrlRef.current = bridgeUrl;
400
+
306
401
  const srcdoc = useMemo(() => {
307
402
  if (liveMode) return undefined; // src mode: the proxy injects the bridge
308
403
  const injection = buildSrcdocInjection({
@@ -310,9 +405,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
310
405
  isLight: isLightTheme(),
311
406
  hostTheme,
312
407
  diffActive: !!diffActive,
408
+ bridgeScriptUrl: bridgeUrl,
313
409
  });
314
410
  return injectIntoHead(rawHtml, injection);
315
- }, [liveMode, rawHtml, hostTheme, diffActive]);
411
+ }, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
412
+
413
+ // Error state for the bridgeScriptUrl path only: the inline path never
414
+ // sets it (no timer, and a version mismatch there can only be a forged
415
+ // message, which is warned about and otherwise ignored).
416
+ const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
417
+ // A version-mismatch banner is dismissible (the older bridge keeps
418
+ // working); reset whenever the error itself changes.
419
+ const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
420
+ const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
421
+ const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
422
+ onBridgeUnavailableRef.current = onBridgeUnavailable;
423
+ // The timeout is read through a ref at arming time: the timer is armed
424
+ // once per document load (URL or srcdoc change), never re-armed by a
425
+ // later prop change, so a host adjusting bridgeReadyTimeoutMs after the
426
+ // bridge is ready can never produce a false timeout.
427
+ const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
428
+ bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
429
+ useEffect(() => {
430
+ if (!bridgeUrl || srcdoc === undefined) return;
431
+ setBridgeError(null);
432
+ setBridgeErrorDismissed(false);
433
+ const url = bridgeUrl;
434
+ const timeoutMs = bridgeReadyTimeoutMsRef.current;
435
+ readyTimerRef.current = setTimeout(() => {
436
+ readyTimerRef.current = null;
437
+ setBridgeError({ kind: "timeout", url, timeoutMs });
438
+ }, timeoutMs);
439
+ return () => {
440
+ if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
441
+ readyTimerRef.current = null;
442
+ };
443
+ }, [bridgeUrl, srcdoc]);
444
+ useEffect(() => {
445
+ setBridgeErrorDismissed(false);
446
+ if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
447
+ }, [bridgeError]);
316
448
 
317
449
  const handleResize = useCallback((height: number) => {
318
450
  if (liveMode) return; // live surfaces are full-viewport; height is ignored
@@ -358,6 +490,63 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
358
490
  [handleYieldPointer],
359
491
  );
360
492
 
493
+ // Unanchored union: the bridge reports ids with no live representation,
494
+ // completed here with what the bridge cannot see (textless page rows it
495
+ // was never asked to restore) and minus locally minted ids the host
496
+ // swapped out of `annotations`. Bridge reports deliver as they arrive
497
+ // (pass-through timing); a prop-side change delivers only when the union
498
+ // actually changes, so a viewer with nothing to complete delivers exactly
499
+ // the bridge list, exactly when the bridge posts it.
500
+ const onUnanchoredChangeRef = useRef(onUnanchoredChange);
501
+ onUnanchoredChangeRef.current = onUnanchoredChange;
502
+ const annotationsRef = useRef(annotations);
503
+ annotationsRef.current = annotations;
504
+ const bridgeUnanchoredRef = useRef<readonly string[]>([]);
505
+ const lastDeliveredUnanchoredRef = useRef("[]");
506
+ const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
507
+ // The bridge's first report for the current document is the one that
508
+ // follows the restore batch (the parent asks for it with
509
+ // report-unanchored, and the bridge answers after its next complete
510
+ // pass, empty set included). Nothing is delivered before it: a host
511
+ // that acknowledges "the first report after a reload" must get the
512
+ // post-restore set, never a prop-side set computed at mount.
513
+ const bridgeReportedRef = useRef(false);
514
+ // An in-place document swap (rawHtml or src changes on one instance):
515
+ // the old document may still emit through the same contentWindow before
516
+ // the new one is ready. From the swap until the next ready, bridge
517
+ // reports belong to the old document and are dropped, and the
518
+ // last-delivered key is reset so the new document's first answer is
519
+ // delivered even when it equals the old one. First mount is not a swap.
520
+ const awaitingReadyRef = useRef(false);
521
+ const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
522
+ const previousIdentity = documentIdentityRef.current;
523
+ if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
524
+ awaitingReadyRef.current = true;
525
+ bridgeReportedRef.current = false;
526
+ lastDeliveredUnanchoredRef.current = "[]";
527
+ bridgeUnanchoredRef.current = [];
528
+ }
529
+ documentIdentityRef.current = { rawHtml, src };
530
+ const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
531
+ const key = JSON.stringify(ids);
532
+ if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
533
+ lastDeliveredUnanchoredRef.current = key;
534
+ onUnanchoredChangeRef.current?.(ids);
535
+ }, []);
536
+ const handleBridgeUnanchored = useCallback((ids: string[]) => {
537
+ if (awaitingReadyRef.current) return;
538
+ bridgeUnanchoredRef.current = ids;
539
+ bridgeReportedRef.current = true;
540
+ deliverUnanchored(
541
+ mergeUnanchoredIds({
542
+ bridgeIds: ids,
543
+ annotations: annotationsRef.current,
544
+ createdIds: createdIdsRef.current,
545
+ }),
546
+ false,
547
+ );
548
+ }, [deliverUnanchored]);
549
+
361
550
  const hook = useHtmlAnnotation({
362
551
  iframeRef,
363
552
  enabled: !readOnly,
@@ -370,8 +559,23 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
370
559
  live: liveSession,
371
560
  onPageChange,
372
561
  onBridgePointer: handleBridgePointer,
373
- onUnanchoredChange,
562
+ onUnanchoredChange: handleBridgeUnanchored,
563
+ maxAdditionalTargets,
564
+ scrollBehavior,
374
565
  });
566
+ createdIdsRef.current = hook.createdAnnotationIds;
567
+
568
+ useEffect(() => {
569
+ if (!bridgeReportedRef.current) return;
570
+ deliverUnanchored(
571
+ mergeUnanchoredIds({
572
+ bridgeIds: bridgeUnanchoredRef.current,
573
+ annotations,
574
+ createdIds: hook.createdAnnotationIds,
575
+ }),
576
+ true,
577
+ );
578
+ }, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
375
579
 
376
580
  const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
377
581
 
@@ -427,6 +631,33 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
427
631
  const live = liveSessionRef.current;
428
632
  if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
429
633
  if (isBridgeReadyMessage(e.data)) {
634
+ // Protocol stamp: one console warning on drift, naming both
635
+ // versions. The ready is still honored (an older bridge answers
636
+ // every message shape it knows); on the bridgeScriptUrl path the
637
+ // surface additionally shows its error banner, because there the
638
+ // drift is a real deployment state (a cached asset from a previous
639
+ // package version) rather than a forged message.
640
+ const verdict = checkBridgeProtocolVersion(e.data);
641
+ const url = bridgeUrlRef.current;
642
+ if (!verdict.ok) {
643
+ console.warn(formatBridgeProtocolWarning(verdict, url));
644
+ }
645
+ if (url) {
646
+ if (readyTimerRef.current !== null) {
647
+ clearTimeout(readyTimerRef.current);
648
+ readyTimerRef.current = null;
649
+ }
650
+ setBridgeError(
651
+ verdict.ok
652
+ ? null
653
+ : {
654
+ kind: "version-mismatch",
655
+ url,
656
+ expectedVersion: verdict.expected,
657
+ reportedVersion: verdict.reported,
658
+ },
659
+ );
660
+ }
430
661
  setIframeReadyVersion((version) => version + 1);
431
662
  setVimBridgePhase("inactive");
432
663
  setVimHudCommand(null);
@@ -547,6 +778,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
547
778
  if (restorable.length > 0) {
548
779
  hook.applyAnnotations(restorable);
549
780
  }
781
+ // A fresh document: the bridge starts from an empty set and would stay
782
+ // silent when everything restores. Ask for one complete report after
783
+ // this restore batch (posted after it, so the answering pass sees it),
784
+ // which becomes this document's first delivery, empty set included.
785
+ awaitingReadyRef.current = false;
786
+ bridgeReportedRef.current = false;
787
+ postToBridge({ type: `${PREFIX}report-unanchored` });
550
788
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
551
789
 
552
790
  // Live page navigation with a ready iframe: explicitly clear the previous
@@ -569,6 +807,11 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
569
807
  type: `${PREFIX}sync-annotations`,
570
808
  annotations: buildSyncNumbering(annotations),
571
809
  });
810
+ // A new page is a new restore batch: report its complete set once, and
811
+ // deliver nothing computed against the previous page's report until
812
+ // that answer arrives.
813
+ bridgeReportedRef.current = false;
814
+ postToBridge({ type: `${PREFIX}report-unanchored` });
572
815
  }, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
573
816
 
574
817
  // Placed-marker numbering is parent-authoritative and matches the
@@ -815,6 +1058,37 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
815
1058
  {actionButtons}
816
1059
  </div>
817
1060
  )}
1061
+ {/* bridgeScriptUrl path only: the bridge did not come up (no
1062
+ ready within the timeout, or a stale asset's version). Floated
1063
+ over the top of the iframe so it never changes the layout the
1064
+ page renders in; the page itself stays visible. */}
1065
+ {bridgeError && !bridgeErrorDismissed && (
1066
+ <div
1067
+ role="alert"
1068
+ data-print-hide
1069
+ data-bridge-error={bridgeError.kind}
1070
+ className="absolute inset-x-0 top-0 z-20 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive backdrop-blur-sm"
1071
+ style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
1072
+ >
1073
+ <span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
1074
+ {/* Only the mismatch state is dismissible: the older bridge
1075
+ still works there. A timeout leaves a dead surface, so
1076
+ that banner stays. Inline styles on purpose: hosts that
1077
+ build the guides.show viewer scan this file for utility
1078
+ classes, and this banner must not grow that stylesheet. */}
1079
+ {bridgeError.kind === "version-mismatch" && (
1080
+ <button
1081
+ type="button"
1082
+ data-bridge-error-dismiss
1083
+ aria-label="Dismiss"
1084
+ style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
1085
+ onClick={() => setBridgeErrorDismissed(true)}
1086
+ >
1087
+ Dismiss
1088
+ </button>
1089
+ )}
1090
+ </div>
1091
+ )}
818
1092
  {/* Live proxied-app mode navigates a real loopback origin: no
819
1093
  sandbox (the user's own app needs cookies, storage, and
820
1094
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
@@ -877,10 +1151,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
877
1151
  positionMode="center-above"
878
1152
  element={hook.toolbarState.element}
879
1153
  copyText={hook.toolbarState.selectionText}
880
- // HTML/live surfaces are comment-only: no Delete, no quick
881
- // labels (onQuickLabel deliberately not passed). The markdown
882
- // surface keeps the full toolbar.
1154
+ // HTML/live surfaces are comment-only: no Delete, no label
1155
+ // picker, no Alt+digit labels (commentOnly). Exactly ONE label
1156
+ // affordance is restored: the hardcoded 👍 "Looks good". The
1157
+ // wrapper filters by id as defense in depth, so no present or
1158
+ // future toolbar path can emit an arbitrary label here.
883
1159
  commentOnly
1160
+ onQuickLabel={(label) => {
1161
+ if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
1162
+ }}
884
1163
  onAnnotate={hook.handleAnnotate}
885
1164
  onRequestComment={hook.handleRequestComment}
886
1165
  onClose={hook.handleToolbarClose}
@@ -898,6 +1177,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
898
1177
  isGlobal={false}
899
1178
  draftKey={`html:${hook.commentPopover.draftKey}`}
900
1179
  onSubmit={hook.handleCommentSubmit}
1180
+ // Pinpoint clicks open this composer directly, so it carries
1181
+ // the surface's one-click "Looks good" (the global composer
1182
+ // does not: a document-wide thumbs-up is not a thing).
1183
+ onQuickLookGood={hook.handleCommentLooksGood}
901
1184
  onClose={hook.handleCommentClose}
902
1185
  skillReferences
903
1186
  onAskAI={onAskAI}