@plannotator/ui 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- 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 +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- 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 +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- 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/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
|
@@ -23,13 +23,26 @@ export interface ActivatedAIProvider extends AIProviderOption {
|
|
|
23
23
|
|
|
24
24
|
export function useAIProviderActivation(options: {
|
|
25
25
|
onCapabilities: (providers: ActivatedAIProvider[], defaultProvider: string | null) => void;
|
|
26
|
+
/**
|
|
27
|
+
* The Ask AI providers the server currently lists. When given, an id not in
|
|
28
|
+
* it is never activated: at page load the selection can still hold a saved
|
|
29
|
+
* provider id (e.g. `codex-sdk`) for one render before the resolver moves it
|
|
30
|
+
* to what the server offers, and activating that id would start its model
|
|
31
|
+
* discovery even though Ask AI does not offer it (a session-bridge server
|
|
32
|
+
* lists only the bridge). Omitted keeps the old behavior.
|
|
33
|
+
*/
|
|
34
|
+
providers?: ReadonlyArray<Pick<AIProviderOption, 'id'>>;
|
|
26
35
|
}) {
|
|
27
36
|
const activatedRef = useRef<Set<string>>(new Set());
|
|
28
37
|
const onCapabilitiesRef = useRef(options.onCapabilities);
|
|
29
38
|
onCapabilitiesRef.current = options.onCapabilities;
|
|
39
|
+
const providersRef = useRef(options.providers);
|
|
40
|
+
providersRef.current = options.providers;
|
|
30
41
|
|
|
31
42
|
return useCallback((providerId: string | null | undefined) => {
|
|
32
43
|
if (!providerId || activatedRef.current.has(providerId)) return;
|
|
44
|
+
const listed = providersRef.current;
|
|
45
|
+
if (listed && !listed.some(provider => provider.id === providerId)) return;
|
|
33
46
|
activatedRef.current.add(providerId);
|
|
34
47
|
fetch(`/api/ai/capabilities?activate=${encodeURIComponent(providerId)}`)
|
|
35
48
|
.then(res => (res.ok ? res.json() : null))
|
|
@@ -56,9 +56,9 @@ export function useAIProviderConfig({
|
|
|
56
56
|
};
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
// A provider picked for this page only (
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }).
|
|
473
|
+
getDraftTransport().save(payload, { keepalive: false }).then(retryIfRefused, () => {});
|
|
437
474
|
}
|
|
438
475
|
// Otherwise silent failure — draft is best-effort.
|
|
439
476
|
});
|
|
440
477
|
}, []);
|
|
478
|
+
const persistNowRef = useRef(persistNow);
|
|
479
|
+
persistNowRef.current = persistNow;
|
|
441
480
|
|
|
442
481
|
const scheduleDraftSave = useCallback(() => {
|
|
443
482
|
if (!canPersistRef.current || !hasMountedRef.current) return;
|
|
@@ -457,6 +496,13 @@ export function useAnnotationDraft({
|
|
|
457
496
|
|
|
458
497
|
const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
|
|
459
498
|
|
|
499
|
+
const flushPendingSave = useCallback(() => {
|
|
500
|
+
if (timerRef.current === null) return;
|
|
501
|
+
clearTimeout(timerRef.current);
|
|
502
|
+
timerRef.current = null;
|
|
503
|
+
persistNow(false);
|
|
504
|
+
}, [persistNow]);
|
|
505
|
+
|
|
460
506
|
// Flush a pending save when the page is backgrounded or closed — otherwise
|
|
461
507
|
// the last debounce window of typing is lost on tab close, and reopening
|
|
462
508
|
// the (still-running) session would restore a draft missing those
|
|
@@ -519,5 +565,5 @@ export function useAnnotationDraft({
|
|
|
519
565
|
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
|
|
520
566
|
}, []);
|
|
521
567
|
|
|
522
|
-
return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft };
|
|
568
|
+
return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
|
|
523
569
|
}
|
|
@@ -11,6 +11,7 @@ import type { Annotation, EditorMode, ImageAttachment } from '../types';
|
|
|
11
11
|
import { AnnotationType } from '../types';
|
|
12
12
|
import type { QuickLabel } from '../utils/quickLabels';
|
|
13
13
|
import { getIdentity } from '../utils/identity';
|
|
14
|
+
import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight';
|
|
14
15
|
import { transformPlainText } from '../utils/inlineTransforms';
|
|
15
16
|
|
|
16
17
|
// --- Exported state types ---
|
|
@@ -28,6 +29,9 @@ export interface CommentPopoverState {
|
|
|
28
29
|
initialText?: string;
|
|
29
30
|
source?: any;
|
|
30
31
|
draftKey: string;
|
|
32
|
+
/** `data-block-id`s of the blocks the pending selection covers, in
|
|
33
|
+
* document order (a host maps them to source lines for Ask AI). */
|
|
34
|
+
blockIds?: string[];
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface QuickLabelPickerState {
|
|
@@ -62,6 +66,27 @@ function commentDraftTargetKey(source: any, selectedText: string): string {
|
|
|
62
66
|
return `selection:${startKey}:${endKey}:${selectedText}`;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** The blocks a pending selection covers: a math source names its own block;
|
|
70
|
+
* a text selection is read off the highlighter's pending marks, each mapped
|
|
71
|
+
* to its nearest `[data-block-id]` (the block its annotation would carry). */
|
|
72
|
+
function selectionBlockIds(highlighter: any, source: any): string[] {
|
|
73
|
+
if (isMathAnnotationSource(source)) return [source.blockId];
|
|
74
|
+
if (!highlighter || !source?.id) return [];
|
|
75
|
+
let doms: unknown[] = [];
|
|
76
|
+
try {
|
|
77
|
+
doms = highlighter.getDoms(source.id) ?? [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const ids: string[] = [];
|
|
82
|
+
for (const dom of doms) {
|
|
83
|
+
if (!(dom instanceof HTMLElement)) continue;
|
|
84
|
+
const id = dom.closest<HTMLElement>('[data-block-id]')?.dataset.blockId;
|
|
85
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
86
|
+
}
|
|
87
|
+
return ids;
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
type MathAnnotationTarget = {
|
|
66
91
|
element: HTMLElement;
|
|
67
92
|
blockId: string;
|
|
@@ -283,6 +308,37 @@ const paintedTextOf = (doms: readonly HTMLElement[]): string => {
|
|
|
283
308
|
return painted;
|
|
284
309
|
};
|
|
285
310
|
|
|
311
|
+
/** The block element a painted highlight sits in, found the way an
|
|
312
|
+
* annotation's `blockId` is found when it is created (walk up from the
|
|
313
|
+
* parent of the first highlighted node to the nearest `data-block-id`), so an
|
|
314
|
+
* unchanged document resolves every restore to the block it was made in. */
|
|
315
|
+
const blockElementOf = (dom: Node | null | undefined): HTMLElement | null => {
|
|
316
|
+
let parent = dom?.parentElement ?? null;
|
|
317
|
+
while (parent && !parent.dataset.blockId) parent = parent.parentElement;
|
|
318
|
+
return parent;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** Where `quote` starts in `text`, matching whitespace-insensitively (the
|
|
322
|
+
* same comparison the restore verification uses: a quote that crossed a
|
|
323
|
+
* block boundary or a soft wrap carries different whitespace than the
|
|
324
|
+
* block's text). 0 when it is not there, as at creation. */
|
|
325
|
+
const quoteOffsetIn = (text: string, quote: string): number => {
|
|
326
|
+
const exact = text.indexOf(quote);
|
|
327
|
+
if (exact !== -1) return exact;
|
|
328
|
+
const needle = compactText(quote);
|
|
329
|
+
if (!needle) return 0;
|
|
330
|
+
// Compact the text while remembering each kept character's original index.
|
|
331
|
+
const positions: number[] = [];
|
|
332
|
+
let compact = '';
|
|
333
|
+
for (let i = 0; i < text.length; i++) {
|
|
334
|
+
if (/\s/.test(text[i])) continue;
|
|
335
|
+
compact += text[i];
|
|
336
|
+
positions.push(i);
|
|
337
|
+
}
|
|
338
|
+
const at = compact.indexOf(needle);
|
|
339
|
+
return at === -1 ? 0 : positions[at];
|
|
340
|
+
};
|
|
341
|
+
|
|
286
342
|
/**
|
|
287
343
|
* Tags whose boxes a browser separates with a line break in a selection string.
|
|
288
344
|
*
|
|
@@ -661,9 +717,34 @@ export interface AnnotationRestoreReport {
|
|
|
661
717
|
/** Ids the pass considered — including ones already painted, which are
|
|
662
718
|
* anchored by definition. A host clears their unanchored marks. */
|
|
663
719
|
attempted: string[];
|
|
664
|
-
/** Of those, the ones left with no highlight
|
|
665
|
-
* resolved onto the wrong text
|
|
720
|
+
/** Of those, the ones left with no highlight: neither the stored positions
|
|
721
|
+
* (absent, unresolvable, or resolved onto the wrong text) nor a search for
|
|
722
|
+
* the quote found it in the document. */
|
|
666
723
|
unanchored: string[];
|
|
724
|
+
/** Annotations whose stored `blockId` no longer names where their text is,
|
|
725
|
+
* because the document changed since they were made (a draft restored
|
|
726
|
+
* after an edit, a plan revision). Each entry says where the text is NOW:
|
|
727
|
+
* `blockId` is the block the highlight landed in, or `''` when the text is
|
|
728
|
+
* gone (every unanchored id is listed here too). Annotations whose stored
|
|
729
|
+
* block still holds their text are never listed, so an unchanged document
|
|
730
|
+
* reports nothing here. A host that exports line numbers from `blockId`
|
|
731
|
+
* should write these back. Optional so older reporters (the diagram
|
|
732
|
+
* overlay) need not produce it. */
|
|
733
|
+
moved?: RestoredAnchor[];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Where a restored annotation's text sits now (see `moved`). */
|
|
737
|
+
export interface RestoredAnchor {
|
|
738
|
+
id: string;
|
|
739
|
+
/** The block the highlight landed in; `''` when the text is gone. */
|
|
740
|
+
blockId: string;
|
|
741
|
+
/** Offset of the quote in that block's text (same rule as creation);
|
|
742
|
+
* absent when the text is gone. */
|
|
743
|
+
startOffset?: number;
|
|
744
|
+
/** The stored `startMeta`/`endMeta` did not lead to the text (unresolvable,
|
|
745
|
+
* or onto other text) and should be dropped so later restores search by
|
|
746
|
+
* text instead. False when the stored positions themselves were verified. */
|
|
747
|
+
positionsStale: boolean;
|
|
667
748
|
}
|
|
668
749
|
|
|
669
750
|
/** Annotation UI state and mutation commands owned by one rendered document. */
|
|
@@ -1097,9 +1178,36 @@ export function useAnnotationHighlighter({
|
|
|
1097
1178
|
|
|
1098
1179
|
const attempted: string[] = [];
|
|
1099
1180
|
const unanchored: string[] = [];
|
|
1181
|
+
const moved: RestoredAnchor[] = [];
|
|
1182
|
+
|
|
1183
|
+
// Record where a restored annotation's text sits when its stored blockId
|
|
1184
|
+
// no longer names that place. Block ids are positional (`block-N`), so
|
|
1185
|
+
// after the document changed the stored id can name a different block,
|
|
1186
|
+
// and a line label read from it would point at the wrong line. Nothing is
|
|
1187
|
+
// recorded while the stored block still holds the quote — an unchanged
|
|
1188
|
+
// document, or a quote that also appears in the stored block — so the
|
|
1189
|
+
// stored id (and the export built from it) stays exactly as it was.
|
|
1190
|
+
const noteAnchor = (ann: Annotation, firstDom: Node | null | undefined, positionsStale: boolean) => {
|
|
1191
|
+
if (!ann.blockId) return;
|
|
1192
|
+
const blockEl = blockElementOf(firstDom);
|
|
1193
|
+
const blockId = blockEl?.dataset.blockId;
|
|
1194
|
+
if (!blockEl || !blockId || blockId === ann.blockId) return;
|
|
1195
|
+
const stored = containerRef.current?.querySelector(
|
|
1196
|
+
`[data-block-id="${escapeAttrValue(ann.blockId)}"]`,
|
|
1197
|
+
);
|
|
1198
|
+
const quote = compactText(ann.originalText);
|
|
1199
|
+
if (stored && quote && compactText(stored.textContent ?? '').includes(quote)) return;
|
|
1200
|
+
moved.push({ id: ann.id, blockId, startOffset: quoteOffsetIn(blockEl.textContent ?? '', ann.originalText), positionsStale });
|
|
1201
|
+
};
|
|
1100
1202
|
|
|
1101
1203
|
anns.forEach(ann => {
|
|
1102
1204
|
if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
|
|
1205
|
+
// Diff-view comments (`diff-block-N`, drawn by the diff view) and
|
|
1206
|
+
// checkbox toggles (raw-markdown quotes keyed by their block) are never
|
|
1207
|
+
// text highlights. Callers filter them out; skipping them here as well
|
|
1208
|
+
// keeps a stray one from being reported unanchored or moved, which would
|
|
1209
|
+
// rewrite its blockId.
|
|
1210
|
+
if (!annotationOwnsHighlight(ann)) return;
|
|
1103
1211
|
// A comment on a rendered diagram part has no text anchor: the
|
|
1104
1212
|
// diagram overlay restores it against its render and reports its own
|
|
1105
1213
|
// verdict, so it is neither attempted nor unanchored here (the same
|
|
@@ -1128,7 +1236,8 @@ export function useAnnotationHighlighter({
|
|
|
1128
1236
|
if (existingManual) return;
|
|
1129
1237
|
|
|
1130
1238
|
const mathTargets = findMathElementsForAnnotation(ann);
|
|
1131
|
-
|
|
1239
|
+
const paintedMath = mathTargets.length > 0;
|
|
1240
|
+
if (paintedMath) {
|
|
1132
1241
|
applyMathTargets(mathTargets, ann.id, ann.type);
|
|
1133
1242
|
if (!ann.startMeta && !ann.endMeta && !ann.mathTargets?.length) {
|
|
1134
1243
|
return;
|
|
@@ -1160,6 +1269,7 @@ export function useAnnotationHighlighter({
|
|
|
1160
1269
|
} else if (ann.type === AnnotationType.COMMENT) {
|
|
1161
1270
|
highlighter.addClass('comment', ann.id);
|
|
1162
1271
|
}
|
|
1272
|
+
if (!paintedMath) noteAnchor(ann, restoredDoms[0] as Node, false);
|
|
1163
1273
|
return;
|
|
1164
1274
|
}
|
|
1165
1275
|
}
|
|
@@ -1169,9 +1279,15 @@ export function useAnnotationHighlighter({
|
|
|
1169
1279
|
const range = findTextInDOM(ann.originalText);
|
|
1170
1280
|
if (!range) {
|
|
1171
1281
|
if (rejectedRestoreText !== null) {
|
|
1172
|
-
unanchored.push(ann.id);
|
|
1173
1282
|
onRestoreMismatchRef.current?.(ann, rejectedRestoreText);
|
|
1174
1283
|
}
|
|
1284
|
+
// Nothing painted it: not the stored positions (absent, unresolvable,
|
|
1285
|
+
// or onto other text) and not the search. A formula the math path
|
|
1286
|
+
// painted above is anchored, whatever its quote search says.
|
|
1287
|
+
if (!paintedMath) {
|
|
1288
|
+
unanchored.push(ann.id);
|
|
1289
|
+
moved.push({ id: ann.id, blockId: '', positionsStale: true });
|
|
1290
|
+
}
|
|
1175
1291
|
console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
|
|
1176
1292
|
return;
|
|
1177
1293
|
}
|
|
@@ -1229,6 +1345,8 @@ export function useAnnotationHighlighter({
|
|
|
1229
1345
|
return;
|
|
1230
1346
|
}
|
|
1231
1347
|
|
|
1348
|
+
if (!paintedMath) noteAnchor(ann, textNodes[0].node, true);
|
|
1349
|
+
|
|
1232
1350
|
textNodes.reverse().forEach(({ node, start, end }) => {
|
|
1233
1351
|
try {
|
|
1234
1352
|
const nodeRange = document.createRange();
|
|
@@ -1259,7 +1377,9 @@ export function useAnnotationHighlighter({
|
|
|
1259
1377
|
}
|
|
1260
1378
|
});
|
|
1261
1379
|
|
|
1262
|
-
if (attempted.length > 0)
|
|
1380
|
+
if (attempted.length > 0) {
|
|
1381
|
+
onRestoreReportRef.current?.({ attempted, unanchored, ...(moved.length > 0 ? { moved } : {}) });
|
|
1382
|
+
}
|
|
1263
1383
|
}, [findMathElementsForAnnotation, findTextInDOM, verifyRestoredContent]);
|
|
1264
1384
|
|
|
1265
1385
|
const removeHighlight = useCallback((id: string) => {
|
|
@@ -1394,6 +1514,7 @@ export function useAnnotationHighlighter({
|
|
|
1394
1514
|
selectedText: source.text,
|
|
1395
1515
|
source,
|
|
1396
1516
|
draftKey: commentDraftTargetKey(source, source.text),
|
|
1517
|
+
blockIds: selectionBlockIds(highlighter, source),
|
|
1397
1518
|
});
|
|
1398
1519
|
} else if (effectiveMode === 'quickLabel') {
|
|
1399
1520
|
pendingSourceRef.current = source;
|
|
@@ -1525,6 +1646,7 @@ export function useAnnotationHighlighter({
|
|
|
1525
1646
|
selectedText: source.text,
|
|
1526
1647
|
source,
|
|
1527
1648
|
draftKey: commentDraftTargetKey(source, source.text),
|
|
1649
|
+
blockIds: [source.blockId],
|
|
1528
1650
|
});
|
|
1529
1651
|
return;
|
|
1530
1652
|
}
|
|
@@ -1671,6 +1793,7 @@ export function useAnnotationHighlighter({
|
|
|
1671
1793
|
selectedText: source.text,
|
|
1672
1794
|
source,
|
|
1673
1795
|
draftKey: commentDraftTargetKey(source, source.text),
|
|
1796
|
+
blockIds: [source.blockId],
|
|
1674
1797
|
});
|
|
1675
1798
|
return;
|
|
1676
1799
|
}
|
|
@@ -1815,6 +1938,7 @@ export function useAnnotationHighlighter({
|
|
|
1815
1938
|
initialText: initialChar,
|
|
1816
1939
|
source: toolbarState.source,
|
|
1817
1940
|
draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText),
|
|
1941
|
+
blockIds: selectionBlockIds(highlighterRef.current, toolbarState.source),
|
|
1818
1942
|
});
|
|
1819
1943
|
setToolbarState(null);
|
|
1820
1944
|
};
|
|
@@ -120,10 +120,25 @@ interface UseExternalAnnotationsReturn<T> {
|
|
|
120
120
|
clearExternalAnnotations: (source?: string) => void;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** The agent session that opened this review closed it (`session-closed` on the stream). */
|
|
124
|
+
export interface ExternalSessionClosed {
|
|
125
|
+
unsentAnnotations: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
123
128
|
export function useExternalAnnotations<T extends { id: string; source?: string }>(
|
|
124
|
-
options?: {
|
|
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
|
+
}
|