@plannotator/ui 0.50.0 → 0.51.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 (53) hide show
  1. package/HANDOFF.md +144 -11
  2. package/components/AISettingsTab.tsx +32 -0
  3. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  4. package/components/AgentsTab.tsx +9 -9
  5. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  6. package/components/CommentPopover.tsx +32 -18
  7. package/components/DecisionControl.tsx +2 -7
  8. package/components/DiagramBlock.tsx +46 -4
  9. package/components/DocBadges.tsx +12 -8
  10. package/components/PlanHeaderMenu.tsx +1 -1
  11. package/components/Settings.tsx +73 -1
  12. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  13. package/components/Viewer.tsx +4 -0
  14. package/components/ai/AIProviderBar.tsx +18 -1
  15. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  16. package/components/ai/SessionAskNotice.tsx +33 -21
  17. package/components/blocks/QuestionBlock.tsx +24 -19
  18. package/components/blocks/QuestionContext.tsx +81 -0
  19. package/components/diagram/DiagramComposer.tsx +54 -3
  20. package/components/diagram/DiagramViewer.tsx +31 -2
  21. package/components/diagram/index.ts +1 -1
  22. package/components/html-viewer/HtmlViewer.tsx +13 -3
  23. package/components/html-viewer/bridge-script.asset.js +124 -8
  24. package/components/html-viewer/bridge-script.ts +124 -8
  25. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  26. package/hooks/useAIChat.ts +26 -5
  27. package/hooks/useAIProviderActivation.ts +13 -0
  28. package/hooks/useAIProviderConfig.ts +3 -3
  29. package/hooks/useActiveSection.ts +3 -2
  30. package/hooks/useAgentToolSetting.ts +33 -0
  31. package/hooks/useAnnotationDraft.ts +53 -7
  32. package/hooks/useAnnotationHighlighter.ts +101 -5
  33. package/hooks/useExternalAnnotations.ts +21 -1
  34. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  35. package/hooks/useLatchedTrue.ts +15 -0
  36. package/hooks/useLinkedDoc.ts +64 -4
  37. package/hooks/useModelCatalogs.ts +9 -2
  38. package/package.json +2 -2
  39. package/styles.css +1 -1
  40. package/themes/gruvbox.css +6 -0
  41. package/types.ts +13 -0
  42. package/utils/agentClosed.ts +33 -0
  43. package/utils/agentToolAnnouncement.ts +109 -0
  44. package/utils/agentToolSetting.ts +108 -0
  45. package/utils/aiProvider.ts +13 -40
  46. package/utils/annotationOwnsHighlight.ts +27 -0
  47. package/utils/annotationScope.ts +15 -0
  48. package/utils/askSessionAnnouncement.ts +107 -0
  49. package/utils/blockTargeting.ts +61 -2
  50. package/utils/diagramAskAI.ts +161 -0
  51. package/utils/parser.ts +236 -110
  52. package/utils/questionAnswers.ts +3 -1
  53. package/utils/quickLabels.ts +2 -2
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { AIContext } from '@plannotator/core';
3
3
  import type { AIQuestion, AIResponse } from '../types';
4
4
  import { generateId } from '../utils/generateId';
5
+ import { SESSION_ASK_ERROR_CODES } from '../utils/aiProvider';
5
6
 
6
7
  export interface AIChatEntry {
7
8
  question: AIQuestion;
@@ -91,7 +92,8 @@ export function buildDefaultPrompt(params: AskAIParams): string {
91
92
  const label = params.scope.label ? `Re: ${params.scope.label}` : 'Re: selected text';
92
93
  const source = params.scope.sourcePath ? `\nSource: ${params.scope.sourcePath}` : '';
93
94
  const selection = params.scope.text ? `\n\nSelected text:\n\`\`\`\n${params.scope.text}\n\`\`\`` : '';
94
- return `${label}${source}${selection}\n\n${params.prompt}`;
95
+ const detail = params.scope.detail?.trim() ? `\n\nSelected element:\n${params.scope.detail.trim()}` : '';
96
+ return `${label}${source}${selection}${detail}\n\n${params.prompt}`;
95
97
  }
96
98
 
97
99
  // General question (no explicit file/line/selection): tell the agent what the
@@ -289,8 +291,8 @@ export function useAIChat({
289
291
  return safeAbort(sessionId);
290
292
  }, []);
291
293
 
292
- // The params each question was asked with, so a busy/gone answer can be
293
- // re-asked ("Ask when it finishes", "Ask a separate AI instead").
294
+ // The params each question was asked with, so a busy answer can be
295
+ // re-asked ("Ask when it finishes", "Interrupt and ask now").
294
296
  const paramsByQuestionRef = useRef(new Map<string, AskAIParams>());
295
297
 
296
298
  const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
@@ -420,6 +422,25 @@ export function useAIChat({
420
422
  description: msg.description,
421
423
  toolUseId: msg.toolUseId,
422
424
  }]);
425
+ } else if (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver) {
426
+ // "Ask this session": the person typed into the session while it
427
+ // answered. The answer so far stands; the reason is a note under
428
+ // it, not an error that would replace it.
429
+ updateMessages(prev =>
430
+ prev.map(m =>
431
+ m.question.id === questionId
432
+ ? {
433
+ ...m,
434
+ response: {
435
+ ...m.response,
436
+ notice: typeof msg.error === 'string' ? msg.error : undefined,
437
+ ...(m.response.status && { status: undefined }),
438
+ isStreaming: false,
439
+ },
440
+ }
441
+ : m
442
+ )
443
+ );
423
444
  } else if (msg.type === 'error') {
424
445
  updateMessages(prev =>
425
446
  prev.map(m =>
@@ -498,8 +519,8 @@ export function useAIChat({
498
519
 
499
520
  /**
500
521
  * Re-ask an earlier question in place of its answer: the busy-session choice
501
- * ("Ask when it finishes" / "Interrupt and ask now") and the fallback to a
502
- * separate AI after "Ask this session" reports the session gone or blocked.
522
+ * ("Ask when it finishes" / "Interrupt and ask now"). `providerId` re-asks
523
+ * on another provider for hosts that offer one.
503
524
  */
504
525
  const retry = useCallback((questionId: string, options: AIRetryOptions = {}) => {
505
526
  const original = paramsByQuestionRef.current.get(questionId);
@@ -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 ---
@@ -283,6 +284,37 @@ const paintedTextOf = (doms: readonly HTMLElement[]): string => {
283
284
  return painted;
284
285
  };
285
286
 
287
+ /** The block element a painted highlight sits in, found the way an
288
+ * annotation's `blockId` is found when it is created (walk up from the
289
+ * parent of the first highlighted node to the nearest `data-block-id`), so an
290
+ * unchanged document resolves every restore to the block it was made in. */
291
+ const blockElementOf = (dom: Node | null | undefined): HTMLElement | null => {
292
+ let parent = dom?.parentElement ?? null;
293
+ while (parent && !parent.dataset.blockId) parent = parent.parentElement;
294
+ return parent;
295
+ };
296
+
297
+ /** Where `quote` starts in `text`, matching whitespace-insensitively (the
298
+ * same comparison the restore verification uses: a quote that crossed a
299
+ * block boundary or a soft wrap carries different whitespace than the
300
+ * block's text). 0 when it is not there, as at creation. */
301
+ const quoteOffsetIn = (text: string, quote: string): number => {
302
+ const exact = text.indexOf(quote);
303
+ if (exact !== -1) return exact;
304
+ const needle = compactText(quote);
305
+ if (!needle) return 0;
306
+ // Compact the text while remembering each kept character's original index.
307
+ const positions: number[] = [];
308
+ let compact = '';
309
+ for (let i = 0; i < text.length; i++) {
310
+ if (/\s/.test(text[i])) continue;
311
+ compact += text[i];
312
+ positions.push(i);
313
+ }
314
+ const at = compact.indexOf(needle);
315
+ return at === -1 ? 0 : positions[at];
316
+ };
317
+
286
318
  /**
287
319
  * Tags whose boxes a browser separates with a line break in a selection string.
288
320
  *
@@ -661,9 +693,34 @@ export interface AnnotationRestoreReport {
661
693
  /** Ids the pass considered — including ones already painted, which are
662
694
  * anchored by definition. A host clears their unanchored marks. */
663
695
  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. */
696
+ /** Of those, the ones left with no highlight: neither the stored positions
697
+ * (absent, unresolvable, or resolved onto the wrong text) nor a search for
698
+ * the quote found it in the document. */
666
699
  unanchored: string[];
700
+ /** Annotations whose stored `blockId` no longer names where their text is,
701
+ * because the document changed since they were made (a draft restored
702
+ * after an edit, a plan revision). Each entry says where the text is NOW:
703
+ * `blockId` is the block the highlight landed in, or `''` when the text is
704
+ * gone (every unanchored id is listed here too). Annotations whose stored
705
+ * block still holds their text are never listed, so an unchanged document
706
+ * reports nothing here. A host that exports line numbers from `blockId`
707
+ * should write these back. Optional so older reporters (the diagram
708
+ * overlay) need not produce it. */
709
+ moved?: RestoredAnchor[];
710
+ }
711
+
712
+ /** Where a restored annotation's text sits now (see `moved`). */
713
+ export interface RestoredAnchor {
714
+ id: string;
715
+ /** The block the highlight landed in; `''` when the text is gone. */
716
+ blockId: string;
717
+ /** Offset of the quote in that block's text (same rule as creation);
718
+ * absent when the text is gone. */
719
+ startOffset?: number;
720
+ /** The stored `startMeta`/`endMeta` did not lead to the text (unresolvable,
721
+ * or onto other text) and should be dropped so later restores search by
722
+ * text instead. False when the stored positions themselves were verified. */
723
+ positionsStale: boolean;
667
724
  }
668
725
 
669
726
  /** Annotation UI state and mutation commands owned by one rendered document. */
@@ -1097,9 +1154,36 @@ export function useAnnotationHighlighter({
1097
1154
 
1098
1155
  const attempted: string[] = [];
1099
1156
  const unanchored: string[] = [];
1157
+ const moved: RestoredAnchor[] = [];
1158
+
1159
+ // Record where a restored annotation's text sits when its stored blockId
1160
+ // no longer names that place. Block ids are positional (`block-N`), so
1161
+ // after the document changed the stored id can name a different block,
1162
+ // and a line label read from it would point at the wrong line. Nothing is
1163
+ // recorded while the stored block still holds the quote — an unchanged
1164
+ // document, or a quote that also appears in the stored block — so the
1165
+ // stored id (and the export built from it) stays exactly as it was.
1166
+ const noteAnchor = (ann: Annotation, firstDom: Node | null | undefined, positionsStale: boolean) => {
1167
+ if (!ann.blockId) return;
1168
+ const blockEl = blockElementOf(firstDom);
1169
+ const blockId = blockEl?.dataset.blockId;
1170
+ if (!blockEl || !blockId || blockId === ann.blockId) return;
1171
+ const stored = containerRef.current?.querySelector(
1172
+ `[data-block-id="${escapeAttrValue(ann.blockId)}"]`,
1173
+ );
1174
+ const quote = compactText(ann.originalText);
1175
+ if (stored && quote && compactText(stored.textContent ?? '').includes(quote)) return;
1176
+ moved.push({ id: ann.id, blockId, startOffset: quoteOffsetIn(blockEl.textContent ?? '', ann.originalText), positionsStale });
1177
+ };
1100
1178
 
1101
1179
  anns.forEach(ann => {
1102
1180
  if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
1181
+ // Diff-view comments (`diff-block-N`, drawn by the diff view) and
1182
+ // checkbox toggles (raw-markdown quotes keyed by their block) are never
1183
+ // text highlights. Callers filter them out; skipping them here as well
1184
+ // keeps a stray one from being reported unanchored or moved, which would
1185
+ // rewrite its blockId.
1186
+ if (!annotationOwnsHighlight(ann)) return;
1103
1187
  // A comment on a rendered diagram part has no text anchor: the
1104
1188
  // diagram overlay restores it against its render and reports its own
1105
1189
  // verdict, so it is neither attempted nor unanchored here (the same
@@ -1128,7 +1212,8 @@ export function useAnnotationHighlighter({
1128
1212
  if (existingManual) return;
1129
1213
 
1130
1214
  const mathTargets = findMathElementsForAnnotation(ann);
1131
- if (mathTargets.length > 0) {
1215
+ const paintedMath = mathTargets.length > 0;
1216
+ if (paintedMath) {
1132
1217
  applyMathTargets(mathTargets, ann.id, ann.type);
1133
1218
  if (!ann.startMeta && !ann.endMeta && !ann.mathTargets?.length) {
1134
1219
  return;
@@ -1160,6 +1245,7 @@ export function useAnnotationHighlighter({
1160
1245
  } else if (ann.type === AnnotationType.COMMENT) {
1161
1246
  highlighter.addClass('comment', ann.id);
1162
1247
  }
1248
+ if (!paintedMath) noteAnchor(ann, restoredDoms[0] as Node, false);
1163
1249
  return;
1164
1250
  }
1165
1251
  }
@@ -1169,9 +1255,15 @@ export function useAnnotationHighlighter({
1169
1255
  const range = findTextInDOM(ann.originalText);
1170
1256
  if (!range) {
1171
1257
  if (rejectedRestoreText !== null) {
1172
- unanchored.push(ann.id);
1173
1258
  onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
1174
1259
  }
1260
+ // Nothing painted it: not the stored positions (absent, unresolvable,
1261
+ // or onto other text) and not the search. A formula the math path
1262
+ // painted above is anchored, whatever its quote search says.
1263
+ if (!paintedMath) {
1264
+ unanchored.push(ann.id);
1265
+ moved.push({ id: ann.id, blockId: '', positionsStale: true });
1266
+ }
1175
1267
  console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
1176
1268
  return;
1177
1269
  }
@@ -1229,6 +1321,8 @@ export function useAnnotationHighlighter({
1229
1321
  return;
1230
1322
  }
1231
1323
 
1324
+ if (!paintedMath) noteAnchor(ann, textNodes[0].node, true);
1325
+
1232
1326
  textNodes.reverse().forEach(({ node, start, end }) => {
1233
1327
  try {
1234
1328
  const nodeRange = document.createRange();
@@ -1259,7 +1353,9 @@ export function useAnnotationHighlighter({
1259
1353
  }
1260
1354
  });
1261
1355
 
1262
- if (attempted.length > 0) onRestoreReportRef.current?.({ attempted, unanchored });
1356
+ if (attempted.length > 0) {
1357
+ onRestoreReportRef.current?.({ attempted, unanchored, ...(moved.length > 0 ? { moved } : {}) });
1358
+ }
1263
1359
  }, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
1264
1360
 
1265
1361
  const removeHighlight = useCallback((id: string) => {
@@ -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
+ }