@plannotator/ui 0.50.0 → 0.52.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 (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. package/utils/serverSession.ts +85 -0
@@ -23,13 +23,26 @@ export interface ActivatedAIProvider extends AIProviderOption {
23
23
 
24
24
  export function useAIProviderActivation(options: {
25
25
  onCapabilities: (providers: ActivatedAIProvider[], defaultProvider: string | null) => void;
26
+ /**
27
+ * The Ask AI providers the server currently lists. When given, an id not in
28
+ * it is never activated: at page load the selection can still hold a saved
29
+ * provider id (e.g. `codex-sdk`) for one render before the resolver moves it
30
+ * to what the server offers, and activating that id would start its model
31
+ * discovery even though Ask AI does not offer it (a session-bridge server
32
+ * lists only the bridge). Omitted keeps the old behavior.
33
+ */
34
+ providers?: ReadonlyArray<Pick<AIProviderOption, 'id'>>;
26
35
  }) {
27
36
  const activatedRef = useRef<Set<string>>(new Set());
28
37
  const onCapabilitiesRef = useRef(options.onCapabilities);
29
38
  onCapabilitiesRef.current = options.onCapabilities;
39
+ const providersRef = useRef(options.providers);
40
+ providersRef.current = options.providers;
30
41
 
31
42
  return useCallback((providerId: string | null | undefined) => {
32
43
  if (!providerId || activatedRef.current.has(providerId)) return;
44
+ const listed = providersRef.current;
45
+ if (listed && !listed.some(provider => provider.id === providerId)) return;
33
46
  activatedRef.current.add(providerId);
34
47
  fetch(`/api/ai/capabilities?activate=${encodeURIComponent(providerId)}`)
35
48
  .then(res => (res.ok ? res.json() : null))
@@ -56,9 +56,9 @@ export function useAIProviderConfig({
56
56
  };
57
57
  });
58
58
 
59
- // A provider picked for this page only (the "Ask a separate AI instead"
60
- // fallback from "Ask this session"): never written to the cookie, but kept
61
- // across capability refreshes so the resolver does not flip back.
59
+ // A provider picked for this page only (`applyConfigChange(..., { persist:
60
+ // false })`): never written to the cookie, but kept across capability
61
+ // refreshes so the resolver does not flip back.
62
62
  const sessionPickRef = useRef<string | null>(null);
63
63
 
64
64
  // Auto-resolve provider/model once capabilities are known.
@@ -21,8 +21,9 @@ export function useActiveSection(
21
21
  const viewport = scrollElement ?? contentContainer;
22
22
  if (!contentContainer || !viewport) return;
23
23
 
24
- // Find all heading elements with data-block-id
25
- const headings = contentContainer.querySelectorAll('[data-block-type="heading"]');
24
+ // Only document headings: a heading inside a question card's context has
25
+ // no block id and is not a TOC section.
26
+ const headings = contentContainer.querySelectorAll('[data-block-type="heading"][data-block-id]');
26
27
  if (headings.length === 0) return;
27
28
 
28
29
  // Track which headings are currently intersecting
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'react';
2
+ import {
3
+ parseAgentToolSetting,
4
+ saveAgentToolSetting,
5
+ withSavedAgentTool,
6
+ type AgentToolSetting,
7
+ } from '../utils/agentToolSetting';
8
+
9
+ export interface AgentToolSettingState {
10
+ /** Undefined until a serverConfig reporting a tool host is adopted. */
11
+ readonly setting: AgentToolSetting | undefined;
12
+ /** Takes the agent tool fields from a `/api/plan` or `/api/diff` serverConfig. */
13
+ readonly adopt: (serverConfig: unknown) => void;
14
+ /** Writes the switch; resolves once config.json holds it, rejects with a readable Error. */
15
+ readonly save: (enabled: boolean) => Promise<void>;
16
+ }
17
+
18
+ /**
19
+ * The agent tool switch for an App: the Settings row and the one-time offer
20
+ * read the same state, so turning the tool on from the offer shows in
21
+ * Settings at once, and vice versa.
22
+ */
23
+ export function useAgentToolSetting(): AgentToolSettingState {
24
+ const [setting, setSetting] = useState<AgentToolSetting | undefined>(undefined);
25
+ const adopt = useCallback((serverConfig: unknown) => {
26
+ setSetting(parseAgentToolSetting(serverConfig));
27
+ }, []);
28
+ const save = useCallback(async (enabled: boolean) => {
29
+ await saveAgentToolSetting(enabled);
30
+ setSetting((current) => (current ? withSavedAgentTool(current, enabled) : current));
31
+ }, []);
32
+ return { setting, adopt, save };
33
+ }
@@ -41,17 +41,30 @@ const DEBOUNCE_MS = 500;
41
41
  * - `load` returns the raw stored body (or null) plus the generation the
42
42
  * store reports when there is NO draft (the default reads `draftGeneration`
43
43
  * from the 404 body) so the client can resume past a tombstone.
44
+ * - `save` MAY resolve `{ staleGeneration }` when the store refused the body
45
+ * as stale (the default reads it from a `409 { draftGeneration }` that is
46
+ * not marked `decided: true`, which
47
+ * annotate servers answer when the file's path copy was deleted or moved
48
+ * ahead by another session). The hook then raises its counter above that
49
+ * generation and saves again, once. Resolving nothing keeps the old
50
+ * behavior, so a host transport that never refuses needs no change.
44
51
  */
45
52
  export interface DraftTransport {
46
53
  /** GET the draft. `data` is the raw stored body (null if none). `generation`
47
54
  is the store's reported generation when there is no draft (null otherwise). */
48
55
  load(): Promise<{ data: unknown | null; generation: number | null }>;
49
- /** Persist the draft body. `keepalive` requests best-effort delivery on close. */
50
- save(body: object, opts: { keepalive: boolean }): Promise<void>;
56
+ /** Persist the draft body. `keepalive` requests best-effort delivery on close.
57
+ Resolves `{ staleGeneration }` when the store refused the body as stale. */
58
+ save(body: object, opts: { keepalive: boolean }): Promise<void | DraftSaveRefused>;
51
59
  /** Generation-gated tombstone delete. */
52
60
  remove(generation: number, opts: { keepalive: boolean }): Promise<void>;
53
61
  }
54
62
 
63
+ /** A save the store refused as stale: the highest generation it knows. */
64
+ export interface DraftSaveRefused {
65
+ staleGeneration: number;
66
+ }
67
+
55
68
  /**
56
69
  * Default transport — Plannotator's `/api/draft` fetches, moved verbatim.
57
70
  * `save` rejects on failure (the keepalive retry stays in the hook so its
@@ -73,7 +86,15 @@ const defaultDraftTransport: DraftTransport = {
73
86
  const payload = JSON.stringify(body);
74
87
  const headers = { 'Content-Type': 'application/json' };
75
88
  return fetch('/api/draft', { method: 'POST', headers, body: payload, keepalive }).then(
76
- () => {},
89
+ async (res): Promise<void | DraftSaveRefused> => {
90
+ if (res.status !== 409) return;
91
+ const data = (await res.json().catch(() => null)) as (MissingDraftData & { decided?: boolean }) | null;
92
+ // The review was decided: saving again would put back comments that
93
+ // were just sent, so a decided refusal is never retried.
94
+ if (data?.decided === true) return;
95
+ const staleGeneration = readDraftGeneration(data?.draftGeneration);
96
+ return staleGeneration !== null ? { staleGeneration } : undefined;
97
+ },
77
98
  );
78
99
  },
79
100
  remove(generation, { keepalive }) {
@@ -281,6 +302,10 @@ interface UseAnnotationDraftResult {
281
302
  scheduleDraftSaveAfterSubmitFailure: () => void;
282
303
  getDraftGeneration: () => number;
283
304
  dismissDraft: () => void;
305
+ /** Send a pending (debounced) save now, if there is one. For a session
306
+ that ends WITHOUT a decision body (the agent closed it): the comment
307
+ typed just before must still reach the kept draft. */
308
+ flushPendingSave: () => void;
284
309
  }
285
310
 
286
311
  export function useAnnotationDraft({
@@ -393,7 +418,7 @@ export function useAnnotationDraft({
393
418
  });
394
419
  }, [isApiMode, isSharedSession]);
395
420
 
396
- const persistNow = useCallback((keepalive: boolean) => {
421
+ const persistNow = useCallback((keepalive: boolean, retried = false) => {
397
422
  // Re-check: the session may have been submitted while the debounce was
398
423
  // pending — a save landing after submit would resurrect a draft the
399
424
  // server just deleted, ghosting it into the next session for this plan.
@@ -429,15 +454,29 @@ export function useAnnotationDraft({
429
454
  // The transport moves the POST behind the seam; the keepalive retry-on-failure
430
455
  // gate stays in the hook verbatim so a host transport that resolves/rejects on
431
456
  // failure still won't resurrect a superseded save.
432
- getDraftTransport().save(payload, { keepalive }).catch(() => {
457
+ // A refused (stale) save means the store moved past this tab's counter:
458
+ // another session on the same file decided or saved ahead of it. Adopt
459
+ // the store's generation and save again, so the reviewer's comments are
460
+ // not silently left unsaved. Only while this is still the latest save,
461
+ // and only once per save.
462
+ const retryIfRefused = (result: void | DraftSaveRefused) => {
463
+ if (!result || retried) return;
464
+ if (!canPersistRef.current || draftGenerationRef.current !== draftGeneration) return;
465
+ draftGenerationRef.current = Math.max(draftGenerationRef.current, result.staleGeneration);
466
+ persistNowRef.current(false, true);
467
+ };
468
+
469
+ getDraftTransport().save(payload, { keepalive }).then(retryIfRefused, () => {
433
470
  // Chromium caps keepalive bodies (~64KB); retry without it. Completes
434
471
  // fine when the page was only backgrounded, best-effort on close.
435
472
  if (keepalive && canPersistRef.current && draftGenerationRef.current === draftGeneration) {
436
- getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
473
+ getDraftTransport().save(payload, { keepalive: false }).then(retryIfRefused, () => {});
437
474
  }
438
475
  // Otherwise silent failure — draft is best-effort.
439
476
  });
440
477
  }, []);
478
+ const persistNowRef = useRef(persistNow);
479
+ persistNowRef.current = persistNow;
441
480
 
442
481
  const scheduleDraftSave = useCallback(() => {
443
482
  if (!canPersistRef.current || !hasMountedRef.current) return;
@@ -457,6 +496,13 @@ export function useAnnotationDraft({
457
496
 
458
497
  const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
459
498
 
499
+ const flushPendingSave = useCallback(() => {
500
+ if (timerRef.current === null) return;
501
+ clearTimeout(timerRef.current);
502
+ timerRef.current = null;
503
+ persistNow(false);
504
+ }, [persistNow]);
505
+
460
506
  // Flush a pending save when the page is backgrounded or closed — otherwise
461
507
  // the last debounce window of typing is lost on tab close, and reopening
462
508
  // the (still-running) session would restore a draft missing those
@@ -519,5 +565,5 @@ export function useAnnotationDraft({
519
565
  getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
520
566
  }, []);
521
567
 
522
- return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft };
568
+ return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
523
569
  }
@@ -11,6 +11,7 @@ import type { Annotation, EditorMode, ImageAttachment } from '../types';
11
11
  import { AnnotationType } from '../types';
12
12
  import type { QuickLabel } from '../utils/quickLabels';
13
13
  import { getIdentity } from '../utils/identity';
14
+ import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight';
14
15
  import { transformPlainText } from '../utils/inlineTransforms';
15
16
 
16
17
  // --- Exported state types ---
@@ -28,6 +29,9 @@ export interface CommentPopoverState {
28
29
  initialText?: string;
29
30
  source?: any;
30
31
  draftKey: string;
32
+ /** `data-block-id`s of the blocks the pending selection covers, in
33
+ * document order (a host maps them to source lines for Ask AI). */
34
+ blockIds?: string[];
31
35
  }
32
36
 
33
37
  export interface QuickLabelPickerState {
@@ -62,6 +66,27 @@ function commentDraftTargetKey(source: any, selectedText: string): string {
62
66
  return `selection:${startKey}:${endKey}:${selectedText}`;
63
67
  }
64
68
 
69
+ /** The blocks a pending selection covers: a math source names its own block;
70
+ * a text selection is read off the highlighter's pending marks, each mapped
71
+ * to its nearest `[data-block-id]` (the block its annotation would carry). */
72
+ function selectionBlockIds(highlighter: any, source: any): string[] {
73
+ if (isMathAnnotationSource(source)) return [source.blockId];
74
+ if (!highlighter || !source?.id) return [];
75
+ let doms: unknown[] = [];
76
+ try {
77
+ doms = highlighter.getDoms(source.id) ?? [];
78
+ } catch {
79
+ return [];
80
+ }
81
+ const ids: string[] = [];
82
+ for (const dom of doms) {
83
+ if (!(dom instanceof HTMLElement)) continue;
84
+ const id = dom.closest<HTMLElement>('[data-block-id]')?.dataset.blockId;
85
+ if (id && !ids.includes(id)) ids.push(id);
86
+ }
87
+ return ids;
88
+ }
89
+
65
90
  type MathAnnotationTarget = {
66
91
  element: HTMLElement;
67
92
  blockId: string;
@@ -283,6 +308,37 @@ const paintedTextOf = (doms: readonly HTMLElement[]): string => {
283
308
  return painted;
284
309
  };
285
310
 
311
+ /** The block element a painted highlight sits in, found the way an
312
+ * annotation's `blockId` is found when it is created (walk up from the
313
+ * parent of the first highlighted node to the nearest `data-block-id`), so an
314
+ * unchanged document resolves every restore to the block it was made in. */
315
+ const blockElementOf = (dom: Node | null | undefined): HTMLElement | null => {
316
+ let parent = dom?.parentElement ?? null;
317
+ while (parent && !parent.dataset.blockId) parent = parent.parentElement;
318
+ return parent;
319
+ };
320
+
321
+ /** Where `quote` starts in `text`, matching whitespace-insensitively (the
322
+ * same comparison the restore verification uses: a quote that crossed a
323
+ * block boundary or a soft wrap carries different whitespace than the
324
+ * block's text). 0 when it is not there, as at creation. */
325
+ const quoteOffsetIn = (text: string, quote: string): number => {
326
+ const exact = text.indexOf(quote);
327
+ if (exact !== -1) return exact;
328
+ const needle = compactText(quote);
329
+ if (!needle) return 0;
330
+ // Compact the text while remembering each kept character's original index.
331
+ const positions: number[] = [];
332
+ let compact = '';
333
+ for (let i = 0; i < text.length; i++) {
334
+ if (/\s/.test(text[i])) continue;
335
+ compact += text[i];
336
+ positions.push(i);
337
+ }
338
+ const at = compact.indexOf(needle);
339
+ return at === -1 ? 0 : positions[at];
340
+ };
341
+
286
342
  /**
287
343
  * Tags whose boxes a browser separates with a line break in a selection string.
288
344
  *
@@ -661,9 +717,34 @@ export interface AnnotationRestoreReport {
661
717
  /** Ids the pass considered — including ones already painted, which are
662
718
  * anchored by definition. A host clears their unanchored marks. */
663
719
  attempted: string[];
664
- /** Of those, the ones left with no highlight because the stored positions
665
- * resolved onto the wrong text AND the quote was nowhere in the document. */
720
+ /** Of those, the ones left with no highlight: neither the stored positions
721
+ * (absent, unresolvable, or resolved onto the wrong text) nor a search for
722
+ * the quote found it in the document. */
666
723
  unanchored: string[];
724
+ /** Annotations whose stored `blockId` no longer names where their text is,
725
+ * because the document changed since they were made (a draft restored
726
+ * after an edit, a plan revision). Each entry says where the text is NOW:
727
+ * `blockId` is the block the highlight landed in, or `''` when the text is
728
+ * gone (every unanchored id is listed here too). Annotations whose stored
729
+ * block still holds their text are never listed, so an unchanged document
730
+ * reports nothing here. A host that exports line numbers from `blockId`
731
+ * should write these back. Optional so older reporters (the diagram
732
+ * overlay) need not produce it. */
733
+ moved?: RestoredAnchor[];
734
+ }
735
+
736
+ /** Where a restored annotation's text sits now (see `moved`). */
737
+ export interface RestoredAnchor {
738
+ id: string;
739
+ /** The block the highlight landed in; `''` when the text is gone. */
740
+ blockId: string;
741
+ /** Offset of the quote in that block's text (same rule as creation);
742
+ * absent when the text is gone. */
743
+ startOffset?: number;
744
+ /** The stored `startMeta`/`endMeta` did not lead to the text (unresolvable,
745
+ * or onto other text) and should be dropped so later restores search by
746
+ * text instead. False when the stored positions themselves were verified. */
747
+ positionsStale: boolean;
667
748
  }
668
749
 
669
750
  /** Annotation UI state and mutation commands owned by one rendered document. */
@@ -1097,9 +1178,36 @@ export function useAnnotationHighlighter({
1097
1178
 
1098
1179
  const attempted: string[] = [];
1099
1180
  const unanchored: string[] = [];
1181
+ const moved: RestoredAnchor[] = [];
1182
+
1183
+ // Record where a restored annotation's text sits when its stored blockId
1184
+ // no longer names that place. Block ids are positional (`block-N`), so
1185
+ // after the document changed the stored id can name a different block,
1186
+ // and a line label read from it would point at the wrong line. Nothing is
1187
+ // recorded while the stored block still holds the quote — an unchanged
1188
+ // document, or a quote that also appears in the stored block — so the
1189
+ // stored id (and the export built from it) stays exactly as it was.
1190
+ const noteAnchor = (ann: Annotation, firstDom: Node | null | undefined, positionsStale: boolean) => {
1191
+ if (!ann.blockId) return;
1192
+ const blockEl = blockElementOf(firstDom);
1193
+ const blockId = blockEl?.dataset.blockId;
1194
+ if (!blockEl || !blockId || blockId === ann.blockId) return;
1195
+ const stored = containerRef.current?.querySelector(
1196
+ `[data-block-id="${escapeAttrValue(ann.blockId)}"]`,
1197
+ );
1198
+ const quote = compactText(ann.originalText);
1199
+ if (stored && quote && compactText(stored.textContent ?? '').includes(quote)) return;
1200
+ moved.push({ id: ann.id, blockId, startOffset: quoteOffsetIn(blockEl.textContent ?? '', ann.originalText), positionsStale });
1201
+ };
1100
1202
 
1101
1203
  anns.forEach(ann => {
1102
1204
  if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
1205
+ // Diff-view comments (`diff-block-N`, drawn by the diff view) and
1206
+ // checkbox toggles (raw-markdown quotes keyed by their block) are never
1207
+ // text highlights. Callers filter them out; skipping them here as well
1208
+ // keeps a stray one from being reported unanchored or moved, which would
1209
+ // rewrite its blockId.
1210
+ if (!annotationOwnsHighlight(ann)) return;
1103
1211
  // A comment on a rendered diagram part has no text anchor: the
1104
1212
  // diagram overlay restores it against its render and reports its own
1105
1213
  // verdict, so it is neither attempted nor unanchored here (the same
@@ -1128,7 +1236,8 @@ export function useAnnotationHighlighter({
1128
1236
  if (existingManual) return;
1129
1237
 
1130
1238
  const mathTargets = findMathElementsForAnnotation(ann);
1131
- if (mathTargets.length > 0) {
1239
+ const paintedMath = mathTargets.length > 0;
1240
+ if (paintedMath) {
1132
1241
  applyMathTargets(mathTargets, ann.id, ann.type);
1133
1242
  if (!ann.startMeta && !ann.endMeta && !ann.mathTargets?.length) {
1134
1243
  return;
@@ -1160,6 +1269,7 @@ export function useAnnotationHighlighter({
1160
1269
  } else if (ann.type === AnnotationType.COMMENT) {
1161
1270
  highlighter.addClass('comment', ann.id);
1162
1271
  }
1272
+ if (!paintedMath) noteAnchor(ann, restoredDoms[0] as Node, false);
1163
1273
  return;
1164
1274
  }
1165
1275
  }
@@ -1169,9 +1279,15 @@ export function useAnnotationHighlighter({
1169
1279
  const range = findTextInDOM(ann.originalText);
1170
1280
  if (!range) {
1171
1281
  if (rejectedRestoreText !== null) {
1172
- unanchored.push(ann.id);
1173
1282
  onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
1174
1283
  }
1284
+ // Nothing painted it: not the stored positions (absent, unresolvable,
1285
+ // or onto other text) and not the search. A formula the math path
1286
+ // painted above is anchored, whatever its quote search says.
1287
+ if (!paintedMath) {
1288
+ unanchored.push(ann.id);
1289
+ moved.push({ id: ann.id, blockId: '', positionsStale: true });
1290
+ }
1175
1291
  console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
1176
1292
  return;
1177
1293
  }
@@ -1229,6 +1345,8 @@ export function useAnnotationHighlighter({
1229
1345
  return;
1230
1346
  }
1231
1347
 
1348
+ if (!paintedMath) noteAnchor(ann, textNodes[0].node, true);
1349
+
1232
1350
  textNodes.reverse().forEach(({ node, start, end }) => {
1233
1351
  try {
1234
1352
  const nodeRange = document.createRange();
@@ -1259,7 +1377,9 @@ export function useAnnotationHighlighter({
1259
1377
  }
1260
1378
  });
1261
1379
 
1262
- if (attempted.length > 0) onRestoreReportRef.current?.({ attempted, unanchored });
1380
+ if (attempted.length > 0) {
1381
+ onRestoreReportRef.current?.({ attempted, unanchored, ...(moved.length > 0 ? { moved } : {}) });
1382
+ }
1263
1383
  }, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
1264
1384
 
1265
1385
  const removeHighlight = useCallback((id: string) => {
@@ -1394,6 +1514,7 @@ export function useAnnotationHighlighter({
1394
1514
  selectedText: source.text,
1395
1515
  source,
1396
1516
  draftKey: commentDraftTargetKey(source, source.text),
1517
+ blockIds: selectionBlockIds(highlighter, source),
1397
1518
  });
1398
1519
  } else if (effectiveMode === 'quickLabel') {
1399
1520
  pendingSourceRef.current = source;
@@ -1525,6 +1646,7 @@ export function useAnnotationHighlighter({
1525
1646
  selectedText: source.text,
1526
1647
  source,
1527
1648
  draftKey: commentDraftTargetKey(source, source.text),
1649
+ blockIds: [source.blockId],
1528
1650
  });
1529
1651
  return;
1530
1652
  }
@@ -1671,6 +1793,7 @@ export function useAnnotationHighlighter({
1671
1793
  selectedText: source.text,
1672
1794
  source,
1673
1795
  draftKey: commentDraftTargetKey(source, source.text),
1796
+ blockIds: [source.blockId],
1674
1797
  });
1675
1798
  return;
1676
1799
  }
@@ -1815,6 +1938,7 @@ export function useAnnotationHighlighter({
1815
1938
  initialText: initialChar,
1816
1939
  source: toolbarState.source,
1817
1940
  draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText),
1941
+ blockIds: selectionBlockIds(highlighterRef.current, toolbarState.source),
1818
1942
  });
1819
1943
  setToolbarState(null);
1820
1944
  };
@@ -120,10 +120,25 @@ interface UseExternalAnnotationsReturn<T> {
120
120
  clearExternalAnnotations: (source?: string) => void;
121
121
  }
122
122
 
123
+ /** The agent session that opened this review closed it (`session-closed` on the stream). */
124
+ export interface ExternalSessionClosed {
125
+ unsentAnnotations: number;
126
+ }
127
+
123
128
  export function useExternalAnnotations<T extends { id: string; source?: string }>(
124
- options?: { enabled?: boolean },
129
+ options?: {
130
+ enabled?: boolean;
131
+ /**
132
+ * The stream reported that the agent closed this review
133
+ * (`POST /api/host/close`). Only the SSE stream carries it; the polling
134
+ * fallback does not.
135
+ */
136
+ onSessionClosed?: (event: ExternalSessionClosed) => void;
137
+ },
125
138
  ): UseExternalAnnotationsReturn<T> {
126
139
  const enabled = options?.enabled ?? true;
140
+ const onSessionClosedRef = useRef(options?.onSessionClosed);
141
+ onSessionClosedRef.current = options?.onSessionClosed;
127
142
  const [annotations, setAnnotations] = useState<T[]>([]);
128
143
  const versionRef = useRef(0);
129
144
  const fallbackRef = useRef(false);
@@ -175,6 +190,11 @@ export function useExternalAnnotations<T extends { id: string; source?: string }
175
190
  prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a),
176
191
  );
177
192
  break;
193
+ case 'session-closed':
194
+ onSessionClosedRef.current?.({
195
+ unsentAnnotations: typeof parsed.unsentAnnotations === 'number' ? parsed.unsentAnnotations : 0,
196
+ });
197
+ break;
178
198
  }
179
199
  }
180
200
 
@@ -0,0 +1,114 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /** How long after the initial load an announcement may still open. */
4
+ export const FIRST_RUN_ANNOUNCEMENT_WINDOW_MS = 4000;
5
+
6
+ /**
7
+ * How long after the initial load a window blur is still taken for the app's
8
+ * own startup (a viewer focusing its iframe) rather than the reader.
9
+ */
10
+ export const FIRST_RUN_ANNOUNCEMENT_BLUR_GRACE_MS = 1000;
11
+
12
+ type Phase = 'waiting' | 'shown' | 'missed';
13
+
14
+ function isEditable(element: Element | null): boolean {
15
+ if (!(element instanceof HTMLElement)) return false;
16
+ if (element.isContentEditable) return true;
17
+ const tag = element.tagName;
18
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
19
+ if (tag !== 'INPUT') return false;
20
+ const type = (element as HTMLInputElement).type;
21
+ return !['button', 'checkbox', 'radio', 'submit', 'reset', 'range', 'color', 'file', 'image'].includes(type);
22
+ }
23
+
24
+ /**
25
+ * The reader is in something the parent cannot see into: a text field, or an
26
+ * iframe (the raw-HTML and live-app annotate surfaces), where their typing
27
+ * never reaches this document's listeners.
28
+ */
29
+ function isWorkingIn(element: Element | null): boolean {
30
+ return isEditable(element) || element instanceof HTMLIFrameElement;
31
+ }
32
+
33
+ interface Options {
34
+ /** The announcement has not been seen (latched at mount). */
35
+ readonly pending: boolean;
36
+ /** Every other condition for opening holds right now. */
37
+ readonly eligible: boolean;
38
+ /** The initial payload has loaded; the window's clock starts here. */
39
+ readonly armed: boolean;
40
+ readonly windowMs?: number;
41
+ readonly blurGraceMs?: number;
42
+ }
43
+
44
+ /**
45
+ * Lets a non-blocking announcement open only before the reader has started
46
+ * working: before their first pointer press, key press or focus into an
47
+ * editable field, and within `windowMs` of the initial load. An announcement
48
+ * whose conditions arrive later (a slow capabilities answer, say) would take
49
+ * focus mid-sentence, and the Space or Enter that follows would dismiss it
50
+ * unread and spend its cookie. Instead it misses this load and waits for the
51
+ * next one; nothing is written. Once open, it stays until dismissed.
52
+ */
53
+ export function useFirstRunAnnouncementWindow({
54
+ pending,
55
+ eligible,
56
+ armed,
57
+ windowMs = FIRST_RUN_ANNOUNCEMENT_WINDOW_MS,
58
+ blurGraceMs = FIRST_RUN_ANNOUNCEMENT_BLUR_GRACE_MS,
59
+ }: Options): boolean {
60
+ const [phase, setPhase] = useState<Phase>('waiting');
61
+
62
+ // The reader's first move closes the window.
63
+ useEffect(() => {
64
+ if (phase !== 'waiting' || !pending) return;
65
+ const miss = () => setPhase((current) => (current === 'waiting' ? 'missed' : current));
66
+ const onFocusIn = (event: FocusEvent) => {
67
+ if (isEditable(event.target as Element | null)) miss();
68
+ };
69
+ document.addEventListener('pointerdown', miss, true);
70
+ document.addEventListener('keydown', miss, true);
71
+ document.addEventListener('focusin', onFocusIn, true);
72
+ return () => {
73
+ document.removeEventListener('pointerdown', miss, true);
74
+ document.removeEventListener('keydown', miss, true);
75
+ document.removeEventListener('focusin', onFocusIn, true);
76
+ };
77
+ }, [phase, pending]);
78
+
79
+ // Work inside an iframe (raw-HTML or live-app annotate) never reaches the
80
+ // listeners above, but moving into it blurs this window. Counted only after
81
+ // the app's own startup, which may focus a viewer iframe by itself.
82
+ useEffect(() => {
83
+ if (phase !== 'waiting' || !pending || !armed) return;
84
+ const miss = () => setPhase((current) => (current === 'waiting' ? 'missed' : current));
85
+ let listening = false;
86
+ const grace = setTimeout(() => {
87
+ listening = true;
88
+ window.addEventListener('blur', miss);
89
+ }, blurGraceMs);
90
+ return () => {
91
+ clearTimeout(grace);
92
+ if (listening) window.removeEventListener('blur', miss);
93
+ };
94
+ }, [phase, pending, armed, blurGraceMs]);
95
+
96
+ // So does time, counted from the end of the initial load.
97
+ useEffect(() => {
98
+ if (phase !== 'waiting' || !pending || !armed) return;
99
+ const timer = setTimeout(() => {
100
+ setPhase((current) => (current === 'waiting' ? 'missed' : current));
101
+ }, windowMs);
102
+ return () => clearTimeout(timer);
103
+ }, [phase, pending, armed, windowMs]);
104
+
105
+ // Open while the window is still open. Never over a field or a framed page
106
+ // the reader is in (a comment composer, a search box, a raw-HTML page), even
107
+ // an autofocused one.
108
+ useEffect(() => {
109
+ if (phase !== 'waiting' || !pending || !eligible) return;
110
+ setPhase(isWorkingIn(document.activeElement) ? 'missed' : 'shown');
111
+ }, [phase, pending, eligible]);
112
+
113
+ return pending && phase === 'shown';
114
+ }
@@ -0,0 +1,15 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * True from the first render where `value` is true, for the life of the
5
+ * component. The Apps use it to remember that an announcement was shown on
6
+ * this load, so the next one in the chain waits for a later load even after
7
+ * the first is dismissed.
8
+ */
9
+ export function useLatchedTrue(value: boolean): boolean {
10
+ const [latched, setLatched] = useState(value);
11
+ useEffect(() => {
12
+ if (value) setLatched(true);
13
+ }, [value]);
14
+ return latched || value;
15
+ }