@plannotator/ui 0.30.0 → 0.32.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/README.md +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
|
|
2
2
|
import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
|
|
3
|
-
import type
|
|
3
|
+
import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
|
|
4
4
|
import { getIdentity } from "../../utils/identity";
|
|
5
5
|
import type {
|
|
6
6
|
ToolbarState,
|
|
@@ -18,6 +18,21 @@ function nextHtmlAnnId(): string {
|
|
|
18
18
|
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Ids minted by this module for locally created annotations (create-mark).
|
|
22
|
+
* Module-scoped like the sequence above: a host that swaps a local id for
|
|
23
|
+
* its own server id keeps the local mark until it removes it, and the
|
|
24
|
+
* unanchored union needs to recognise such ids whichever viewer instance
|
|
25
|
+
* minted them. Bounded: only ids from this page load, one entry per create. */
|
|
26
|
+
const mintedHtmlAnnIds = new Set<string>();
|
|
27
|
+
|
|
28
|
+
function htmlCommentDraftKey(
|
|
29
|
+
text: string,
|
|
30
|
+
anchor?: HtmlElementAnchor | null,
|
|
31
|
+
targetKey?: string,
|
|
32
|
+
): string {
|
|
33
|
+
return `html-selection:${targetKey ?? JSON.stringify(anchor ?? null)}:${text}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
interface BridgeSelectionMessage {
|
|
22
37
|
type: `${typeof PREFIX}selection`;
|
|
23
38
|
text: string;
|
|
@@ -66,7 +81,30 @@ type BridgeMessage =
|
|
|
66
81
|
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
67
82
|
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
68
83
|
| { type: `${typeof PREFIX}unanchored`; ids: string[] }
|
|
69
|
-
| { type: `${typeof PREFIX}resize`; height: number }
|
|
84
|
+
| { type: `${typeof PREFIX}resize`; height: number }
|
|
85
|
+
| { type: `${typeof PREFIX}page-change`; pageUrl: string };
|
|
86
|
+
|
|
87
|
+
/** Live proxied-app session credentials: the proxy origin messages must come
|
|
88
|
+
* from, and the per-session token every message must echo. */
|
|
89
|
+
export interface HtmlLiveSession {
|
|
90
|
+
origin: string;
|
|
91
|
+
token: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Cap for live-mode page identity strings (mirrors the bridge's slice). */
|
|
95
|
+
export const MAX_PAGE_URL_LENGTH = 2048;
|
|
96
|
+
|
|
97
|
+
/** True when a live-session message event fails the origin or token check.
|
|
98
|
+
* Exported for protocol tests. */
|
|
99
|
+
export function rejectsLiveMessage(
|
|
100
|
+
live: HtmlLiveSession,
|
|
101
|
+
origin: string,
|
|
102
|
+
data: unknown,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (origin !== live.origin) return true;
|
|
105
|
+
if (!isRecord(data) || data.token !== live.token) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
70
108
|
|
|
71
109
|
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
|
|
72
110
|
export interface UseHtmlAnnotationOptions {
|
|
@@ -79,6 +117,12 @@ export interface UseHtmlAnnotationOptions {
|
|
|
79
117
|
selectedAnnotationId: string | null;
|
|
80
118
|
mode: EditorMode;
|
|
81
119
|
onResize?: (height: number) => void;
|
|
120
|
+
/** Live proxied-app session: reject messages that fail the origin or token
|
|
121
|
+
* check before parsing, and stamp token + concrete targetOrigin on every
|
|
122
|
+
* outbound post. Absent for srcdoc sessions (behavior unchanged). */
|
|
123
|
+
live?: HtmlLiveSession;
|
|
124
|
+
/** Live-mode page navigation reports (validated, capped at 2048 chars). */
|
|
125
|
+
onPageChange?: (pageUrl: string) => void;
|
|
82
126
|
/** Validated pointer positions relayed from inside the iframe while a
|
|
83
127
|
* pinpoint draft is open (iframe-local viewport coordinates), with the
|
|
84
128
|
* Shift state observed by the iframe (the parent cannot see modifiers
|
|
@@ -92,9 +136,39 @@ export interface UseHtmlAnnotationOptions {
|
|
|
92
136
|
* empty on recovery. Delivered in readOnly mode too: view-only surfaces
|
|
93
137
|
* are exactly where silently missing markers would go unnoticed. */
|
|
94
138
|
onUnanchoredChange?: (ids: string[]) => void;
|
|
139
|
+
/** Product cap on additional (shift-click) targets per comment, 0..16.
|
|
140
|
+
* Applied at the trust boundary, on submit, on restore, and carried to
|
|
141
|
+
* the bridge on arm-multi-select so the in-page toggle stops at the
|
|
142
|
+
* same number. Absent: the package's 16, and the arm message is unchanged. */
|
|
143
|
+
maxAdditionalTargets?: number;
|
|
144
|
+
/** scrollIntoView behavior for scroll-to (selecting an annotation).
|
|
145
|
+
* Absent: smooth, as before; pass 'auto' to honor reduced motion. */
|
|
146
|
+
scrollBehavior?: 'smooth' | 'auto';
|
|
95
147
|
}
|
|
96
148
|
|
|
97
|
-
|
|
149
|
+
/** Clamp a host cap into the package's bound; anything unusable is the default. */
|
|
150
|
+
export function resolveMaxAdditionalTargets(value: number | undefined): number {
|
|
151
|
+
if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
|
|
152
|
+
return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function postToIframe(
|
|
156
|
+
iframe: HTMLIFrameElement | null,
|
|
157
|
+
msg: Record<string, unknown>,
|
|
158
|
+
live?: HtmlLiveSession | null,
|
|
159
|
+
) {
|
|
160
|
+
if (live) {
|
|
161
|
+
// Live proxied-app sessions: token on every message, concrete targetOrigin.
|
|
162
|
+
// Browsers silently DROP a post whose targetOrigin does not match the
|
|
163
|
+
// receiving window (mid-navigation frames); some DOM environments throw
|
|
164
|
+
// instead, so align with the browser semantics explicitly.
|
|
165
|
+
try {
|
|
166
|
+
iframe?.contentWindow?.postMessage({ ...msg, token: live.token }, live.origin);
|
|
167
|
+
} catch {
|
|
168
|
+
// Dropped, matching browser behavior for unmatched target origins.
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
98
172
|
iframe?.contentWindow?.postMessage(msg, "*");
|
|
99
173
|
}
|
|
100
174
|
|
|
@@ -282,6 +356,13 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
282
356
|
return typeof value.height === "number" && Number.isFinite(value.height)
|
|
283
357
|
? { type: value.type, height: value.height }
|
|
284
358
|
: null;
|
|
359
|
+
case `${PREFIX}page-change`:
|
|
360
|
+
// Live-mode SPA navigation report. Bounded like every bridge string.
|
|
361
|
+
return typeof value.pageUrl === "string"
|
|
362
|
+
&& value.pageUrl.length > 0
|
|
363
|
+
&& value.pageUrl.length <= MAX_PAGE_URL_LENGTH
|
|
364
|
+
? { type: value.type, pageUrl: value.pageUrl }
|
|
365
|
+
: null;
|
|
285
366
|
default:
|
|
286
367
|
return null;
|
|
287
368
|
}
|
|
@@ -301,8 +382,12 @@ export function useHtmlAnnotation({
|
|
|
301
382
|
selectedAnnotationId,
|
|
302
383
|
mode,
|
|
303
384
|
onResize,
|
|
385
|
+
live,
|
|
386
|
+
onPageChange,
|
|
304
387
|
onBridgePointer,
|
|
305
388
|
onUnanchoredChange,
|
|
389
|
+
maxAdditionalTargets,
|
|
390
|
+
scrollBehavior,
|
|
306
391
|
}: UseHtmlAnnotationOptions): Omit<
|
|
307
392
|
UseAnnotationHighlighterReturn,
|
|
308
393
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
@@ -315,6 +400,13 @@ export function useHtmlAnnotation({
|
|
|
315
400
|
flashDraftTarget: (key: string) => void;
|
|
316
401
|
/** Bumped after every target add/remove so the composer can refocus its textarea. */
|
|
317
402
|
composerFocusToken: number;
|
|
403
|
+
/** Composer one-click "Looks good": submits the hardcoded positive label
|
|
404
|
+
* with the same anchor and multi-select targets a typed comment would carry. */
|
|
405
|
+
handleCommentLooksGood: () => void;
|
|
406
|
+
/** Ids this module minted for locally created annotations (create-mark),
|
|
407
|
+
* for the unanchored union: a minted id the host never listed is a
|
|
408
|
+
* swapped-out local mark, not a host row. Read-only, stable identity. */
|
|
409
|
+
createdAnnotationIds: ReadonlySet<string>;
|
|
318
410
|
} {
|
|
319
411
|
const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
|
|
320
412
|
const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
|
|
@@ -351,9 +443,28 @@ export function useHtmlAnnotation({
|
|
|
351
443
|
onSelectRef.current = onSelectAnnotation;
|
|
352
444
|
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
353
445
|
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
446
|
+
const liveRef = useRef<HtmlLiveSession | null>(live ?? null);
|
|
447
|
+
liveRef.current = live ?? null;
|
|
448
|
+
const onPageChangeRef = useRef(onPageChange);
|
|
449
|
+
onPageChangeRef.current = onPageChange;
|
|
450
|
+
// The effective cap and whether the host set one: only an explicit cap
|
|
451
|
+
// rides on arm-multi-select, so an unconfigured viewer posts today's message.
|
|
452
|
+
const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
|
|
453
|
+
maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
|
|
454
|
+
const hostCapRef = useRef(maxAdditionalTargets !== undefined);
|
|
455
|
+
hostCapRef.current = maxAdditionalTargets !== undefined;
|
|
354
456
|
|
|
355
457
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
356
458
|
|
|
459
|
+
/** Post into the iframe with the live token + targetOrigin when a live
|
|
460
|
+
* session is active; srcdoc posts keep targetOrigin "*" and no token. */
|
|
461
|
+
const post = useCallback(
|
|
462
|
+
(msg: Record<string, unknown>) => {
|
|
463
|
+
postToIframe(iframeRef.current, msg, liveRef.current);
|
|
464
|
+
},
|
|
465
|
+
[iframeRef],
|
|
466
|
+
);
|
|
467
|
+
|
|
357
468
|
/**
|
|
358
469
|
* Remove one draft target. Removing the primary promotes the next remaining
|
|
359
470
|
* target (the composer's context text and pending anchor follow it);
|
|
@@ -368,7 +479,7 @@ export function useHtmlAnnotation({
|
|
|
368
479
|
const targets = draftTargetsRef.current;
|
|
369
480
|
const index = targets.findIndex((t) => t.key === key);
|
|
370
481
|
if (index < 0) return;
|
|
371
|
-
|
|
482
|
+
post({ type: `${PREFIX}remove-target`, key });
|
|
372
483
|
const remaining = targets.filter((t) => t.key !== key);
|
|
373
484
|
if (remaining.length === 0) {
|
|
374
485
|
// Final target removed — the draft is cancelled (bridge side already
|
|
@@ -392,7 +503,7 @@ export function useHtmlAnnotation({
|
|
|
392
503
|
setDraftTargets(remaining);
|
|
393
504
|
setComposerFocusToken((t) => t + 1);
|
|
394
505
|
},
|
|
395
|
-
[
|
|
506
|
+
[post],
|
|
396
507
|
);
|
|
397
508
|
|
|
398
509
|
const getOrCreateAnchor = useCallback(() => {
|
|
@@ -429,6 +540,11 @@ export function useHtmlAnnotation({
|
|
|
429
540
|
useEffect(() => {
|
|
430
541
|
function handler(e: MessageEvent<unknown>) {
|
|
431
542
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
543
|
+
// Live sessions verify origin + session token BEFORE parsing. The
|
|
544
|
+
// existing caps stay exactly as they are: live content is friendlier
|
|
545
|
+
// but the trust boundary does not relax.
|
|
546
|
+
const liveSession = liveRef.current;
|
|
547
|
+
if (liveSession && rejectsLiveMessage(liveSession, e.origin, e.data)) return;
|
|
432
548
|
const message = parseBridgeMessage(e.data);
|
|
433
549
|
if (!message) return;
|
|
434
550
|
|
|
@@ -439,6 +555,8 @@ export function useHtmlAnnotation({
|
|
|
439
555
|
&& type !== `${PREFIX}mark-click`
|
|
440
556
|
&& type !== `${PREFIX}unanchored`
|
|
441
557
|
&& type !== `${PREFIX}resize`
|
|
558
|
+
// Page identity is navigation state, not an annotation mutation.
|
|
559
|
+
&& type !== `${PREFIX}page-change`
|
|
442
560
|
) {
|
|
443
561
|
return;
|
|
444
562
|
}
|
|
@@ -450,25 +568,20 @@ export function useHtmlAnnotation({
|
|
|
450
568
|
const anchor = positionAnchor(message.rect);
|
|
451
569
|
if (!anchor) return;
|
|
452
570
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
htmlAnchor: message.anchor,
|
|
468
|
-
});
|
|
469
|
-
pendingTextRef.current = "";
|
|
470
|
-
pendingAnchorRef.current = null;
|
|
471
|
-
} else if (
|
|
571
|
+
// HTML and live-app surfaces are COMMENT-ONLY: redline (auto-DELETION)
|
|
572
|
+
// and quickLabel are markdown-surface features, so both the host's
|
|
573
|
+
// mode and any bridge-posted modeOverride clamp to the plain selection
|
|
574
|
+
// flow here (a hostile page can post modeOverride, so the clamp sits
|
|
575
|
+
// at the trust boundary, not in the host). Persisted DELETION
|
|
576
|
+
// annotations still restore through applyAnnotations — only CREATION
|
|
577
|
+
// is comment-only.
|
|
578
|
+
const requestedMode = message.modeOverride ?? modeRef.current;
|
|
579
|
+
const currentMode =
|
|
580
|
+
requestedMode === "redline" || requestedMode === "quickLabel"
|
|
581
|
+
? "selection"
|
|
582
|
+
: requestedMode;
|
|
583
|
+
|
|
584
|
+
if (
|
|
472
585
|
currentMode === "comment"
|
|
473
586
|
// Pinpoint click-to-pin: the click already chose the target, so skip
|
|
474
587
|
// the intermediate toolbar and go straight to the comment composer.
|
|
@@ -481,6 +594,7 @@ export function useHtmlAnnotation({
|
|
|
481
594
|
anchorEl: anchor,
|
|
482
595
|
contextText: message.text,
|
|
483
596
|
selectedText: message.text,
|
|
597
|
+
draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
|
|
484
598
|
});
|
|
485
599
|
// Pinpoint drafts arm shift-click multi-select: the clicked element
|
|
486
600
|
// becomes the primary target of the (single) draft comment. The
|
|
@@ -496,16 +610,12 @@ export function useHtmlAnnotation({
|
|
|
496
610
|
anchor: message.anchor ?? null,
|
|
497
611
|
},
|
|
498
612
|
]);
|
|
499
|
-
|
|
613
|
+
post({
|
|
500
614
|
type: `${PREFIX}arm-multi-select`,
|
|
501
615
|
key: message.targetKey,
|
|
616
|
+
...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
|
|
502
617
|
});
|
|
503
618
|
}
|
|
504
|
-
} else if (currentMode === "quickLabel") {
|
|
505
|
-
setQuickLabelPicker({
|
|
506
|
-
anchorEl: anchor,
|
|
507
|
-
cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
|
|
508
|
-
});
|
|
509
619
|
} else {
|
|
510
620
|
setToolbarState({
|
|
511
621
|
element: anchor,
|
|
@@ -523,7 +633,7 @@ export function useHtmlAnnotation({
|
|
|
523
633
|
if (
|
|
524
634
|
commentPopoverRef.current
|
|
525
635
|
&& targets.length > 0
|
|
526
|
-
&& targets.length < 1 +
|
|
636
|
+
&& targets.length < 1 + maxTargetsRef.current
|
|
527
637
|
&& !targets.some((t) => t.key === message.key)
|
|
528
638
|
) {
|
|
529
639
|
setDraftTargets([
|
|
@@ -585,7 +695,13 @@ export function useHtmlAnnotation({
|
|
|
585
695
|
// the typing) — otherwise the iframe keeps focus and the bridge eats keys.
|
|
586
696
|
iframeRef.current?.blur();
|
|
587
697
|
setToolbarState(null);
|
|
588
|
-
setCommentPopover({
|
|
698
|
+
setCommentPopover({
|
|
699
|
+
anchorEl: anchor,
|
|
700
|
+
contextText: text,
|
|
701
|
+
selectedText: text,
|
|
702
|
+
initialText: key,
|
|
703
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
704
|
+
});
|
|
589
705
|
}
|
|
590
706
|
|
|
591
707
|
if (type === `${PREFIX}mark-click`) {
|
|
@@ -599,6 +715,10 @@ export function useHtmlAnnotation({
|
|
|
599
715
|
if (type === `${PREFIX}resize`) {
|
|
600
716
|
onResize?.(message.height);
|
|
601
717
|
}
|
|
718
|
+
|
|
719
|
+
if (type === `${PREFIX}page-change`) {
|
|
720
|
+
onPageChangeRef.current?.(message.pageUrl);
|
|
721
|
+
}
|
|
602
722
|
}
|
|
603
723
|
|
|
604
724
|
window.addEventListener("message", handler);
|
|
@@ -609,7 +729,7 @@ export function useHtmlAnnotation({
|
|
|
609
729
|
anchorRef.current = null;
|
|
610
730
|
}
|
|
611
731
|
};
|
|
612
|
-
}, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
732
|
+
}, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
613
733
|
|
|
614
734
|
useEffect(() => {
|
|
615
735
|
if (enabled) return;
|
|
@@ -621,22 +741,25 @@ export function useHtmlAnnotation({
|
|
|
621
741
|
pendingAnchorRef.current = null;
|
|
622
742
|
anchorRef.current?.remove();
|
|
623
743
|
anchorRef.current = null;
|
|
624
|
-
|
|
625
|
-
}, [enabled,
|
|
744
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
745
|
+
}, [enabled, post]);
|
|
626
746
|
|
|
627
747
|
useEffect(() => {
|
|
628
748
|
if (selectedAnnotationId) {
|
|
629
|
-
|
|
749
|
+
post({
|
|
630
750
|
type: `${PREFIX}scroll-to`,
|
|
631
751
|
id: selectedAnnotationId,
|
|
752
|
+
// Only an explicit host preference rides along; the default message
|
|
753
|
+
// is unchanged and the bridge scrolls smoothly as before.
|
|
754
|
+
...(scrollBehavior ? { behavior: scrollBehavior } : {}),
|
|
632
755
|
});
|
|
633
756
|
} else {
|
|
634
|
-
|
|
757
|
+
post({
|
|
635
758
|
type: `${PREFIX}focus-mark`,
|
|
636
759
|
id: null,
|
|
637
760
|
});
|
|
638
761
|
}
|
|
639
|
-
}, [selectedAnnotationId,
|
|
762
|
+
}, [selectedAnnotationId, post, scrollBehavior]);
|
|
640
763
|
|
|
641
764
|
const handleAnnotate = useCallback(
|
|
642
765
|
(type: AnnotationType) => {
|
|
@@ -645,7 +768,8 @@ export function useHtmlAnnotation({
|
|
|
645
768
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
646
769
|
|
|
647
770
|
const id = nextHtmlAnnId();
|
|
648
|
-
|
|
771
|
+
mintedHtmlAnnIds.add(id);
|
|
772
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
649
773
|
onAddRef.current?.({
|
|
650
774
|
id,
|
|
651
775
|
blockId: "",
|
|
@@ -662,7 +786,7 @@ export function useHtmlAnnotation({
|
|
|
662
786
|
pendingTextRef.current = "";
|
|
663
787
|
pendingAnchorRef.current = null;
|
|
664
788
|
},
|
|
665
|
-
[
|
|
789
|
+
[post],
|
|
666
790
|
);
|
|
667
791
|
|
|
668
792
|
const handleRequestComment = useCallback(
|
|
@@ -672,7 +796,13 @@ export function useHtmlAnnotation({
|
|
|
672
796
|
if (!text) return;
|
|
673
797
|
const anchor = anchorRef.current ?? getOrCreateAnchor();
|
|
674
798
|
setToolbarState(null);
|
|
675
|
-
setCommentPopover({
|
|
799
|
+
setCommentPopover({
|
|
800
|
+
anchorEl: anchor,
|
|
801
|
+
contextText: text,
|
|
802
|
+
selectedText: text,
|
|
803
|
+
initialText: initialChar,
|
|
804
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
805
|
+
});
|
|
676
806
|
},
|
|
677
807
|
[getOrCreateAnchor],
|
|
678
808
|
);
|
|
@@ -689,7 +819,7 @@ export function useHtmlAnnotation({
|
|
|
689
819
|
const targets = draftTargetsRef.current;
|
|
690
820
|
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
691
821
|
targets.length > 1
|
|
692
|
-
? targets.slice(1, 1 +
|
|
822
|
+
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
693
823
|
label: t.label,
|
|
694
824
|
text: t.text,
|
|
695
825
|
anchor: t.anchor ?? undefined,
|
|
@@ -697,7 +827,8 @@ export function useHtmlAnnotation({
|
|
|
697
827
|
: undefined;
|
|
698
828
|
|
|
699
829
|
const id = nextHtmlAnnId();
|
|
700
|
-
|
|
830
|
+
mintedHtmlAnnIds.add(id);
|
|
831
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
701
832
|
onAddRef.current?.({
|
|
702
833
|
id,
|
|
703
834
|
blockId: "",
|
|
@@ -718,16 +849,61 @@ export function useHtmlAnnotation({
|
|
|
718
849
|
pendingTextRef.current = "";
|
|
719
850
|
pendingAnchorRef.current = null;
|
|
720
851
|
},
|
|
721
|
-
[
|
|
852
|
+
[post],
|
|
722
853
|
);
|
|
723
854
|
|
|
855
|
+
// The composer's one-click "Looks good" (the restored thumbs-up for
|
|
856
|
+
// comment-only surfaces, where pinpoint clicks land straight in the
|
|
857
|
+
// composer and never see the selection toolbar). Mirrors
|
|
858
|
+
// handleCommentSubmit — same anchor, same multi-select targets — but
|
|
859
|
+
// emits the hardcoded positive label instead of typed prose.
|
|
860
|
+
const handleCommentLooksGood = useCallback(() => {
|
|
861
|
+
if (!enabledRef.current) return;
|
|
862
|
+
const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
|
|
863
|
+
if (!text) return;
|
|
864
|
+
|
|
865
|
+
const targets = draftTargetsRef.current;
|
|
866
|
+
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
867
|
+
targets.length > 1
|
|
868
|
+
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
869
|
+
label: t.label,
|
|
870
|
+
text: t.text,
|
|
871
|
+
anchor: t.anchor ?? undefined,
|
|
872
|
+
}))
|
|
873
|
+
: undefined;
|
|
874
|
+
|
|
875
|
+
const id = nextHtmlAnnId();
|
|
876
|
+
mintedHtmlAnnIds.add(id);
|
|
877
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
878
|
+
onAddRef.current?.({
|
|
879
|
+
id,
|
|
880
|
+
blockId: "",
|
|
881
|
+
startOffset: 0,
|
|
882
|
+
endOffset: 0,
|
|
883
|
+
type: AnnotationType.COMMENT,
|
|
884
|
+
text: THUMBS_UP_LABEL.text,
|
|
885
|
+
originalText: text,
|
|
886
|
+
isQuickLabel: true,
|
|
887
|
+
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
888
|
+
author: getIdentity(),
|
|
889
|
+
createdA: Date.now(),
|
|
890
|
+
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
891
|
+
htmlAdditionalTargets: additionalTargets,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
setCommentPopover(null);
|
|
895
|
+
setDraftTargets([]);
|
|
896
|
+
pendingTextRef.current = "";
|
|
897
|
+
pendingAnchorRef.current = null;
|
|
898
|
+
}, [post]);
|
|
899
|
+
|
|
724
900
|
const handleCommentClose = useCallback(() => {
|
|
725
|
-
|
|
901
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
726
902
|
setCommentPopover(null);
|
|
727
903
|
setDraftTargets([]);
|
|
728
904
|
pendingTextRef.current = "";
|
|
729
905
|
pendingAnchorRef.current = null;
|
|
730
|
-
}, [
|
|
906
|
+
}, [post]);
|
|
731
907
|
|
|
732
908
|
const removeDraftTarget = useCallback(
|
|
733
909
|
(key: string) => {
|
|
@@ -739,17 +915,17 @@ export function useHtmlAnnotation({
|
|
|
739
915
|
|
|
740
916
|
const flashDraftTarget = useCallback(
|
|
741
917
|
(key: string) => {
|
|
742
|
-
|
|
918
|
+
post({ type: `${PREFIX}flash-target`, key });
|
|
743
919
|
},
|
|
744
|
-
[
|
|
920
|
+
[post],
|
|
745
921
|
);
|
|
746
922
|
|
|
747
923
|
const handleToolbarClose = useCallback(() => {
|
|
748
|
-
|
|
924
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
749
925
|
setToolbarState(null);
|
|
750
926
|
pendingTextRef.current = "";
|
|
751
927
|
pendingAnchorRef.current = null;
|
|
752
|
-
}, [
|
|
928
|
+
}, [post]);
|
|
753
929
|
|
|
754
930
|
const applyQuickLabel = useCallback(
|
|
755
931
|
(label: QuickLabel, clearState: () => void) => {
|
|
@@ -757,7 +933,8 @@ export function useHtmlAnnotation({
|
|
|
757
933
|
const text = pendingTextRef.current;
|
|
758
934
|
if (!text) return;
|
|
759
935
|
const id = nextHtmlAnnId();
|
|
760
|
-
|
|
936
|
+
mintedHtmlAnnIds.add(id);
|
|
937
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
761
938
|
onAddRef.current?.({
|
|
762
939
|
id,
|
|
763
940
|
blockId: "",
|
|
@@ -776,7 +953,7 @@ export function useHtmlAnnotation({
|
|
|
776
953
|
pendingTextRef.current = "";
|
|
777
954
|
pendingAnchorRef.current = null;
|
|
778
955
|
},
|
|
779
|
-
[
|
|
956
|
+
[post],
|
|
780
957
|
);
|
|
781
958
|
|
|
782
959
|
const handleQuickLabel = useCallback(
|
|
@@ -790,22 +967,22 @@ export function useHtmlAnnotation({
|
|
|
790
967
|
);
|
|
791
968
|
|
|
792
969
|
const handleQuickLabelPickerDismiss = useCallback(() => {
|
|
793
|
-
|
|
970
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
794
971
|
setQuickLabelPicker(null);
|
|
795
972
|
pendingTextRef.current = "";
|
|
796
973
|
pendingAnchorRef.current = null;
|
|
797
|
-
}, [
|
|
974
|
+
}, [post]);
|
|
798
975
|
|
|
799
976
|
const removeHighlight = useCallback(
|
|
800
977
|
(id: string) => {
|
|
801
|
-
|
|
978
|
+
post({ type: `${PREFIX}remove-mark`, id });
|
|
802
979
|
},
|
|
803
|
-
[
|
|
980
|
+
[post],
|
|
804
981
|
);
|
|
805
982
|
|
|
806
983
|
const clearAllHighlights = useCallback(() => {
|
|
807
|
-
|
|
808
|
-
}, [
|
|
984
|
+
post({ type: `${PREFIX}clear-marks` });
|
|
985
|
+
}, [post]);
|
|
809
986
|
|
|
810
987
|
const applyAnnotations = useCallback(
|
|
811
988
|
(anns: Annotation[]) => {
|
|
@@ -817,8 +994,8 @@ export function useHtmlAnnotation({
|
|
|
817
994
|
const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
|
|
818
995
|
.map((t) => t.anchor)
|
|
819
996
|
.filter((a): a is HtmlElementAnchor => !!a)
|
|
820
|
-
.slice(0,
|
|
821
|
-
|
|
997
|
+
.slice(0, maxTargetsRef.current);
|
|
998
|
+
post({
|
|
822
999
|
type: `${PREFIX}find-and-mark`,
|
|
823
1000
|
id: ann.id,
|
|
824
1001
|
originalText: ann.originalText,
|
|
@@ -830,7 +1007,7 @@ export function useHtmlAnnotation({
|
|
|
830
1007
|
});
|
|
831
1008
|
}
|
|
832
1009
|
},
|
|
833
|
-
[
|
|
1010
|
+
[post],
|
|
834
1011
|
);
|
|
835
1012
|
|
|
836
1013
|
return {
|
|
@@ -842,6 +1019,7 @@ export function useHtmlAnnotation({
|
|
|
842
1019
|
handleToolbarClose,
|
|
843
1020
|
handleRequestComment,
|
|
844
1021
|
handleCommentSubmit,
|
|
1022
|
+
handleCommentLooksGood,
|
|
845
1023
|
handleCommentClose,
|
|
846
1024
|
handleFloatingQuickLabel,
|
|
847
1025
|
handleQuickLabelPickerDismiss,
|
|
@@ -852,5 +1030,6 @@ export function useHtmlAnnotation({
|
|
|
852
1030
|
removeDraftTarget,
|
|
853
1031
|
flashDraftTarget,
|
|
854
1032
|
composerFocusToken,
|
|
1033
|
+
createdAnnotationIds: mintedHtmlAnnIds,
|
|
855
1034
|
};
|
|
856
1035
|
}
|
|
@@ -330,6 +330,7 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
|
|
|
330
330
|
contextText={commentPopover.contextText}
|
|
331
331
|
isGlobal={false}
|
|
332
332
|
initialText={commentPopover.initialText}
|
|
333
|
+
draftKey={`plan-diff:${commentPopover.index}:${commentPopover.diffContext}:${commentPopover.contextText}`}
|
|
333
334
|
onSubmit={handleCommentSubmit}
|
|
334
335
|
onClose={handleCommentClose}
|
|
335
336
|
skillReferences
|
|
@@ -27,6 +27,8 @@ interface FileBrowserProps {
|
|
|
27
27
|
annotationCounts?: Map<string, number>;
|
|
28
28
|
highlightedFiles?: Set<string>;
|
|
29
29
|
editStatuses?: Map<string, FileEditStatus>;
|
|
30
|
+
/** Prevent competing destination changes while a document is activating. */
|
|
31
|
+
selectionPending?: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface FileEditStatus {
|
|
@@ -246,7 +248,8 @@ const TreeNode: React.FC<{
|
|
|
246
248
|
editStatuses?: Map<string, FileEditStatus>;
|
|
247
249
|
workspaceStatus?: WorkspaceStatusPayload;
|
|
248
250
|
forceExpandFolders?: boolean;
|
|
249
|
-
|
|
251
|
+
selectionPending?: boolean;
|
|
252
|
+
}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false, selectionPending = false }) => {
|
|
250
253
|
const folderKey = `${dirPath}:${node.path}`;
|
|
251
254
|
const absolutePath = `${dirPath}/${node.path}`;
|
|
252
255
|
const isExpanded = forceExpandFolders || expandedFolders.has(folderKey);
|
|
@@ -305,6 +308,7 @@ const TreeNode: React.FC<{
|
|
|
305
308
|
editStatuses={editStatuses}
|
|
306
309
|
workspaceStatus={workspaceStatus}
|
|
307
310
|
forceExpandFolders={forceExpandFolders}
|
|
311
|
+
selectionPending={selectionPending}
|
|
308
312
|
/>
|
|
309
313
|
))}
|
|
310
314
|
</>
|
|
@@ -347,7 +351,8 @@ const TreeNode: React.FC<{
|
|
|
347
351
|
onClick={() => {
|
|
348
352
|
if (!isSelectionDisabled) onSelectFile(absolutePath, dirPath);
|
|
349
353
|
}}
|
|
350
|
-
disabled={isSelectionDisabled}
|
|
354
|
+
disabled={isSelectionDisabled || selectionPending}
|
|
355
|
+
aria-busy={isActive && selectionPending ? true : undefined}
|
|
351
356
|
className={`file-tree-item w-full text-left group ${isActive ? "active" : ""} ${fileCount > 0 ? "has-annotations" : ""} ${isHighlighted ? 'file-annotation-flash' : ''} ${isSelectionDisabled ? 'opacity-70 cursor-default' : ''}`}
|
|
352
357
|
style={{ paddingLeft: paddingLeft + 15 }}
|
|
353
358
|
title={isDeleted ? `${node.path} (${editStatus?.status === "missing" ? "missing on disk" : "deleted on disk"})` : node.path}
|
|
@@ -357,6 +362,9 @@ const TreeNode: React.FC<{
|
|
|
357
362
|
</svg>
|
|
358
363
|
<span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
|
|
359
364
|
<div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
|
|
365
|
+
{isActive && selectionPending && (
|
|
366
|
+
<span className="text-primary">Opening…</span>
|
|
367
|
+
)}
|
|
360
368
|
{editMarker && (
|
|
361
369
|
<span
|
|
362
370
|
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[9px] font-semibold leading-none ${editMarker.className}`}
|
|
@@ -393,7 +401,8 @@ const DirSection: React.FC<{
|
|
|
393
401
|
highlightedFiles?: Set<string>;
|
|
394
402
|
editStatuses?: Map<string, FileEditStatus>;
|
|
395
403
|
forceExpandFolders?: boolean;
|
|
396
|
-
|
|
404
|
+
selectionPending?: boolean;
|
|
405
|
+
}> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false, selectionPending = false }) => {
|
|
397
406
|
const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]);
|
|
398
407
|
|
|
399
408
|
if (dir.isLoading) {
|
|
@@ -443,6 +452,7 @@ const DirSection: React.FC<{
|
|
|
443
452
|
editStatuses={editStatuses}
|
|
444
453
|
workspaceStatus={workspaceStatus}
|
|
445
454
|
forceExpandFolders={forceExpandFolders}
|
|
455
|
+
selectionPending={selectionPending}
|
|
446
456
|
/>
|
|
447
457
|
))}
|
|
448
458
|
</div>
|
|
@@ -462,6 +472,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
462
472
|
annotationCounts,
|
|
463
473
|
highlightedFiles,
|
|
464
474
|
editStatuses,
|
|
475
|
+
selectionPending = false,
|
|
465
476
|
}) => {
|
|
466
477
|
const [isFilterOpen, setIsFilterOpen] = React.useState(false);
|
|
467
478
|
const [filterQuery, setFilterQuery] = React.useState("");
|
|
@@ -528,7 +539,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
528
539
|
{showFilterInput ? (
|
|
529
540
|
<div
|
|
530
541
|
onBlur={handleFilterBlur}
|
|
531
|
-
className="flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
|
|
542
|
+
className="file-browser-filter-field flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
|
|
532
543
|
>
|
|
533
544
|
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
|
|
534
545
|
<input
|
|
@@ -556,7 +567,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
556
567
|
setFilterQuery("");
|
|
557
568
|
inputRef.current?.focus();
|
|
558
569
|
}}
|
|
559
|
-
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
|
|
570
|
+
className="file-browser-filter-clear flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
|
|
560
571
|
aria-label="Clear file filter"
|
|
561
572
|
title="Clear file filter"
|
|
562
573
|
>
|
|
@@ -617,6 +628,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
617
628
|
highlightedFiles={highlightedFiles}
|
|
618
629
|
editStatuses={editStatuses}
|
|
619
630
|
forceExpandFolders={isFiltering}
|
|
631
|
+
selectionPending={selectionPending}
|
|
620
632
|
/>
|
|
621
633
|
)}
|
|
622
634
|
</div>
|