@plannotator/ui 0.49.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 (64) hide show
  1. package/HANDOFF.md +202 -1
  2. package/LICENSE +191 -0
  3. package/README.md +23 -0
  4. package/components/AISettingsTab.tsx +33 -1
  5. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  6. package/components/AgentsTab.tsx +9 -9
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/CommentPopover.tsx +32 -18
  9. package/components/DecisionControl.tsx +2 -7
  10. package/components/DiagramBlock.tsx +46 -4
  11. package/components/DiffFileTree.tsx +363 -0
  12. package/components/DocBadges.tsx +12 -8
  13. package/components/ImageLightbox.tsx +66 -0
  14. package/components/PlanHeaderMenu.tsx +1 -1
  15. package/components/ProviderIcons.tsx +15 -3
  16. package/components/Settings.tsx +73 -1
  17. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  18. package/components/Viewer.tsx +5 -28
  19. package/components/ai/AIProviderBar.tsx +20 -3
  20. package/components/ai/DocumentAIChatPanel.tsx +23 -3
  21. package/components/ai/SessionAskNotice.tsx +101 -0
  22. package/components/blocks/QuestionBlock.tsx +24 -19
  23. package/components/blocks/QuestionContext.tsx +81 -0
  24. package/components/diagram/DiagramComposer.tsx +54 -3
  25. package/components/diagram/DiagramViewer.tsx +31 -2
  26. package/components/diagram/index.ts +1 -1
  27. package/components/html-viewer/HtmlViewer.tsx +13 -3
  28. package/components/html-viewer/bridge-script.asset.js +209 -16
  29. package/components/html-viewer/bridge-script.ts +209 -16
  30. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  31. package/configure.ts +12 -0
  32. package/hooks/useAIChat.ts +96 -10
  33. package/hooks/useAIProviderActivation.ts +13 -0
  34. package/hooks/useAIProviderConfig.ts +32 -14
  35. package/hooks/useActiveSection.ts +3 -2
  36. package/hooks/useAgentToolSetting.ts +33 -0
  37. package/hooks/useAnnotationDraft.ts +53 -7
  38. package/hooks/useAnnotationHighlighter.ts +101 -5
  39. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  40. package/hooks/useExternalAnnotations.ts +21 -1
  41. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  42. package/hooks/useLatchedTrue.ts +15 -0
  43. package/hooks/useLinkedDoc.ts +64 -4
  44. package/hooks/useModelCatalogs.ts +9 -2
  45. package/package.json +3 -2
  46. package/styles.css +1 -1
  47. package/themes/gruvbox.css +6 -0
  48. package/types.ts +17 -0
  49. package/utils/agentClosed.ts +33 -0
  50. package/utils/agentToolAnnouncement.ts +109 -0
  51. package/utils/agentToolSetting.ts +108 -0
  52. package/utils/aiPrompt.ts +31 -0
  53. package/utils/aiProvider.ts +41 -0
  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/diffFileTree.ts +203 -0
  60. package/utils/htmlLinkNavigation.ts +87 -3
  61. package/utils/parser.ts +236 -110
  62. package/utils/questionAnswers.ts +3 -1
  63. package/utils/quickLabels.ts +2 -2
  64. package/utils/syntaxTheme.ts +33 -0
@@ -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) => {
@@ -0,0 +1,92 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ getAllFolderPaths,
4
+ getAncestorPaths,
5
+ type DiffFileTreeFile,
6
+ type DiffFileTreeNode,
7
+ } from '../utils/diffFileTree';
8
+
9
+ /** Which folders start open: every folder (the review's behavior), none, or
10
+ * an explicit list of folder paths (`DiffFileTreeNode.path`). */
11
+ export type DiffFileTreeDefaultExpanded = 'all' | 'none' | readonly string[];
12
+
13
+ function initialSet(allFolderPaths: string[], defaultExpanded: DiffFileTreeDefaultExpanded): Set<string> {
14
+ if (defaultExpanded === 'all') return new Set(allFolderPaths);
15
+ if (defaultExpanded === 'none') return new Set();
16
+ return new Set(defaultExpanded);
17
+ }
18
+
19
+ /**
20
+ * Folder open/closed state for a diff file tree, shared by the review's
21
+ * FileTree and the embeddable DiffFileTree:
22
+ * - starts from `defaultExpanded` and resets to it whenever `tree` changes
23
+ * identity (a new diff), so pass a memoized tree;
24
+ * - keeps every ancestor of `activePath` open, so moving the selection always
25
+ * reveals the selected file.
26
+ */
27
+ export function useDiffFileTreeExpansion<F extends DiffFileTreeFile>(
28
+ tree: DiffFileTreeNode<F>[],
29
+ activePath: string | null | undefined,
30
+ defaultExpanded: DiffFileTreeDefaultExpanded = 'all',
31
+ ) {
32
+ const allFolderPaths = useMemo(() => getAllFolderPaths(tree), [tree]);
33
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => initialSet(allFolderPaths, defaultExpanded));
34
+ const [prevTree, setPrevTree] = useState(tree);
35
+
36
+ // Reset when the tree changes (initial render + diff switch).
37
+ if (tree !== prevTree) {
38
+ setPrevTree(tree);
39
+ setExpandedFolders(initialSet(allFolderPaths, defaultExpanded));
40
+ }
41
+
42
+ // Auto-expand ancestors of the active file so keyboard nav always reveals the target.
43
+ useEffect(() => {
44
+ if (activePath) {
45
+ const ancestors = getAncestorPaths(activePath);
46
+ setExpandedFolders((prev) => {
47
+ const missing = ancestors.filter((p) => !prev.has(p));
48
+ if (missing.length === 0) return prev;
49
+ const next = new Set(prev);
50
+ for (const p of missing) next.add(p);
51
+ return next;
52
+ });
53
+ }
54
+ }, [activePath, tree]);
55
+
56
+ const toggleFolder = useCallback((path: string) => {
57
+ setExpandedFolders((prev) => {
58
+ const next = new Set(prev);
59
+ if (next.has(path)) {
60
+ next.delete(path);
61
+ } else {
62
+ next.add(path);
63
+ }
64
+ return next;
65
+ });
66
+ }, []);
67
+
68
+ const setFolderExpanded = useCallback((path: string, expanded: boolean) => {
69
+ setExpandedFolders((prev) => {
70
+ if (prev.has(path) === expanded) return prev;
71
+ const next = new Set(prev);
72
+ if (expanded) next.add(path);
73
+ else next.delete(path);
74
+ return next;
75
+ });
76
+ }, []);
77
+
78
+ const areAllFoldersExpanded = allFolderPaths.length > 0 && allFolderPaths.every((path) => expandedFolders.has(path));
79
+
80
+ const toggleAllFolders = useCallback(() => {
81
+ setExpandedFolders(areAllFoldersExpanded ? new Set() : new Set(allFolderPaths));
82
+ }, [allFolderPaths, areAllFoldersExpanded]);
83
+
84
+ return {
85
+ expandedFolders,
86
+ allFolderPaths,
87
+ areAllFoldersExpanded,
88
+ toggleFolder,
89
+ setFolderExpanded,
90
+ toggleAllFolders,
91
+ };
92
+ }
@@ -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
+ }
@@ -14,6 +14,7 @@ import type { ViewerHandle } from "../components/Viewer";
14
14
  import type { SidebarTab } from "./useSidebar";
15
15
  import type { SourceSaveCapability } from "@plannotator/core/source-save";
16
16
  import type { VersionInfo } from "./usePlanDiff";
17
+ import { annotationOwnsHighlight } from "../utils/annotationOwnsHighlight";
17
18
 
18
19
  export interface LinkedDocLoadData {
19
20
  markdown?: string;
@@ -124,6 +125,29 @@ export interface CachedDocState {
124
125
  versionInfo?: VersionInfo | null;
125
126
  }
126
127
 
128
+ /**
129
+ * The session's documents split the way FEEDBACK must be: the root document
130
+ * (the plan, the annotated file, or the folder itself) once, and every other
131
+ * document once under its own path. `getDocAnnotations()` is NOT this split —
132
+ * it also carries the active document's live state, which the host exports
133
+ * from its own state too, so exporting both printed the open document twice.
134
+ */
135
+ export interface FeedbackDocuments {
136
+ /**
137
+ * The root document's stashed state while a linked document is open. Null
138
+ * while the root itself is the active document: its annotations are then
139
+ * the host's live state, which the host exports itself.
140
+ */
141
+ root: (CachedDocState & { renderAs: DocumentRenderAs }) | null;
142
+ /**
143
+ * Every document other than the root that carries state, keyed by path:
144
+ * the cached documents plus the active linked document's live state. The
145
+ * root's own annotations are never here; a copy of the root's path opened
146
+ * from the root itself is a separate annotation set and is listed by path.
147
+ */
148
+ documents: Map<string, CachedDocState>;
149
+ }
150
+
127
151
  export interface LinkedDocSessionState {
128
152
  root: SavedPlanState;
129
153
  docs: Map<string, CachedDocState>;
@@ -156,8 +180,12 @@ export interface UseLinkedDocReturn {
156
180
  back: () => void;
157
181
  /** Dismiss the current error */
158
182
  dismissError: () => void;
159
- /** All linked doc annotations including the active doc's live state (keyed by filepath) */
183
+ /** All linked doc annotations including the active doc's live state (keyed by filepath).
184
+ * For counts and the cross-file panel; a feedback export reads getFeedbackDocuments(). */
160
185
  getDocAnnotations: () => Map<string, CachedDocState>;
186
+ /** The root document and every other document, each exactly once — what a
187
+ * feedback export reads (see FeedbackDocuments). */
188
+ getFeedbackDocuments: () => FeedbackDocuments;
161
189
  /**
162
190
  * Replace the stored annotations of a document that is NOT the active one —
163
191
  * a cached linked doc, or the stashed source document. This is what lets the
@@ -285,7 +313,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
285
313
  if (saved.annotations.length) {
286
314
  setTimeout(() => {
287
315
  viewerRef.current?.clearAllHighlights();
288
- viewerRef.current?.applySharedAnnotations(saved.annotations);
316
+ viewerRef.current?.applySharedAnnotations(saved.annotations.filter(annotationOwnsHighlight));
289
317
  }, HIGHLIGHT_REAPPLY_DELAY);
290
318
  }
291
319
  }, [
@@ -415,7 +443,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
415
443
  if (cached?.annotations.length) {
416
444
  setTimeout(() => {
417
445
  viewerRef.current?.clearAllHighlights();
418
- viewerRef.current?.applySharedAnnotations(cached.annotations);
446
+ viewerRef.current?.applySharedAnnotations(cached.annotations.filter(annotationOwnsHighlight));
419
447
  }, HIGHLIGHT_REAPPLY_DELAY);
420
448
  }
421
449
  }, [
@@ -562,7 +590,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
562
590
  if (state.root.annotations.length) {
563
591
  setTimeout(() => {
564
592
  viewerRef.current?.clearAllHighlights();
565
- viewerRef.current?.applySharedAnnotations(state.root.annotations);
593
+ viewerRef.current?.applySharedAnnotations(state.root.annotations.filter(annotationOwnsHighlight));
566
594
  }, HIGHLIGHT_REAPPLY_DELAY);
567
595
  }
568
596
  }, [
@@ -644,6 +672,37 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
644
672
  // identity of this callback is what tells memoized readers to recompute.
645
673
  }, [linkedDoc, annotations, globalAttachments, sourceFilePath, sourceConverted, getDocumentMarkdown, storeRevision]);
646
674
 
675
+ const getFeedbackDocuments = useCallback((): FeedbackDocuments => {
676
+ const documents = new Map(docCache.current);
677
+ if (linkedDoc) {
678
+ documents.set(linkedDoc.filepath, {
679
+ annotations: [...annotations],
680
+ globalAttachments: [...globalAttachments],
681
+ markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
682
+ isConverted: linkedDoc.isConverted,
683
+ });
684
+ }
685
+ // A cache entry under the root's own path is NOT the root: it is a copy
686
+ // the reviewer opened from the root itself (an HTML "Home" link, or
687
+ // index.html#intro from index.html — a backlink only routes through
688
+ // back() while a linked doc is open). Its comments are a separate set the
689
+ // counts include, so it is exported under its path, never dropped.
690
+ const saved = linkedDoc ? savedPlanState.current : null;
691
+ return {
692
+ root: saved
693
+ ? {
694
+ annotations: [...saved.annotations],
695
+ globalAttachments: [...saved.globalAttachments],
696
+ markdown: saved.markdown,
697
+ isConverted: !!sourceConverted,
698
+ renderAs: saved.renderAs,
699
+ }
700
+ : null,
701
+ documents,
702
+ };
703
+ // storeRevision: same reason as getDocAnnotations above.
704
+ }, [linkedDoc, annotations, globalAttachments, sourceConverted, getDocumentMarkdown, storeRevision]);
705
+
647
706
  return {
648
707
  isActive: linkedDoc !== null,
649
708
  filepath: linkedDoc?.filepath ?? null,
@@ -654,6 +713,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
654
713
  back,
655
714
  dismissError,
656
715
  getDocAnnotations,
716
+ getFeedbackDocuments,
657
717
  updateStoredAnnotations,
658
718
  snapshotSession,
659
719
  restoreSession,
@@ -14,7 +14,10 @@ export { effortSelectOptions, modelSelectOptions } from '@plannotator/core/model
14
14
  * Claude / Codex model catalogs for the agent launchers (review, Code Tour,
15
15
  * Guided Review) — the SAME lists Ask AI shows. Each is the Ask AI provider's
16
16
  * discovered list, fetched through `/api/ai/capabilities?activate=<id>` (which
17
- * runs the provider's server-cached discovery). Nothing is fetched until a
17
+ * runs the provider's server-cached discovery). When a session bridge makes
18
+ * Ask AI bridge-only, the server reports these under `catalogProviders`
19
+ * instead of `providers`, so the launchers keep them while Ask AI cannot
20
+ * offer them. Nothing is fetched until a
18
21
  * surface asks for one engine with `useModelCatalogs(...).load(engine)`, and
19
22
  * only when that engine is installed, so opening a launcher spawns only the
20
23
  * selected CLI. While a fetch is in flight the static fallback is shown with
@@ -67,7 +70,11 @@ function loadCatalogEntry(engine: CatalogEngine): Promise<LoadedCatalog> {
67
70
  load = fetch(`/api/ai/capabilities?activate=${encodeURIComponent(id)}`)
68
71
  .then((res) => (res.ok ? res.json() : null))
69
72
  .then((data): LoadedCatalog => {
70
- const provider = data?.providers?.find((p: { id?: string; name?: string }) => p.id === id || p.name === id);
73
+ // A server whose Ask AI is a session bridge lists the SDK providers
74
+ // only under `catalogProviders` (never under `providers`, which Ask AI
75
+ // reads), so the launchers look in both.
76
+ const matches = (p: { id?: string; name?: string }) => p.id === id || p.name === id;
77
+ const provider = data?.providers?.find(matches) ?? data?.catalogProviders?.find(matches);
71
78
  const models = provider?.models;
72
79
  if (!Array.isArray(models) || models.length === 0) return failed();
73
80
  // The server answers 200 with its static fallback when discovery
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "exports": {
6
7
  "./components/*": "./components/*.tsx",
@@ -76,7 +77,7 @@
76
77
  "@lezer/highlight": "^1.2.3",
77
78
  "@pierre/diffs": "1.3.6",
78
79
  "@plannotator/atomic-editor": "^0.9.0",
79
- "@plannotator/core": "0.25.9",
80
+ "@plannotator/core": "0.25.11",
80
81
  "@plannotator/markdown-editor": "^0.5.0",
81
82
  "@plannotator/web-highlighter": "^0.8.1",
82
83
  "@tanstack/react-table": "^8.21.3",