@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.
- package/HANDOFF.md +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- 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
|
|
665
|
-
* resolved onto the wrong text
|
|
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
|
-
|
|
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)
|
|
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?: {
|
|
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
|
+
}
|
package/hooks/useLinkedDoc.ts
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|