@plannotator/ui 0.29.1 → 0.31.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 +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +68 -35
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- 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/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 +136 -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/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +215 -39
- package/components/html-viewer/bridge-script.ts +384 -66
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +179 -58
- 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/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/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -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 +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -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,51 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Composer-yield state machine for shift-click multi-select.
|
|
3
|
-
*
|
|
4
|
-
* While Shift is held during an HTML pinpoint draft, the comment composer must
|
|
5
|
-
* get out of the way of the pointer: fade to ~40% as the pointer approaches,
|
|
6
|
-
* and to near-zero + click-through (pointer-events: none) when the pointer is
|
|
7
|
-
* over it — the DOM equivalent of Codex's setIgnoreMouseEvents click-through
|
|
8
|
-
* popup, so the shift-click lands on the iframe beneath.
|
|
9
|
-
*
|
|
10
|
-
* Restoration uses hysteresis so the composer doesn't flicker at the border:
|
|
11
|
-
* once 'over', the pointer must clear the composer by OVER_EXIT px before the
|
|
12
|
-
* state drops back, and 'near' persists until NEAR_EXIT px (> NEAR_ENTER).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type ComposerYieldState = "none" | "near" | "over";
|
|
16
|
-
|
|
17
|
-
/** Distance (px from the composer rect) at which the composer starts fading. */
|
|
18
|
-
export const COMPOSER_YIELD_NEAR_ENTER_PX = 80;
|
|
19
|
-
/** Distance the pointer must exceed before a faded composer fully restores. */
|
|
20
|
-
export const COMPOSER_YIELD_NEAR_EXIT_PX = 96;
|
|
21
|
-
/** Distance the pointer must clear a click-through composer by to restore. */
|
|
22
|
-
export const COMPOSER_YIELD_OVER_EXIT_PX = 48;
|
|
23
|
-
|
|
24
|
-
/** Shortest distance from a point to a rect's edge; <= 0 means inside. */
|
|
25
|
-
export function distanceToRect(
|
|
26
|
-
x: number,
|
|
27
|
-
y: number,
|
|
28
|
-
rect: { left: number; top: number; right: number; bottom: number },
|
|
29
|
-
): number {
|
|
30
|
-
const dx = Math.max(rect.left - x, 0, x - rect.right);
|
|
31
|
-
const dy = Math.max(rect.top - y, 0, y - rect.bottom);
|
|
32
|
-
if (dx === 0 && dy === 0) return 0;
|
|
33
|
-
return Math.hypot(dx, dy);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Next yield state for a pointer at `distance` px from the composer rect. */
|
|
37
|
-
export function computeComposerYield(
|
|
38
|
-
prev: ComposerYieldState,
|
|
39
|
-
distance: number,
|
|
40
|
-
): ComposerYieldState {
|
|
41
|
-
if (distance <= 0) return "over";
|
|
42
|
-
if (prev === "over") {
|
|
43
|
-
// Hysteresis: don't restore until the pointer has moved well away.
|
|
44
|
-
if (distance <= COMPOSER_YIELD_OVER_EXIT_PX) return "over";
|
|
45
|
-
return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? "near" : "none";
|
|
46
|
-
}
|
|
47
|
-
if (prev === "near") {
|
|
48
|
-
return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? "near" : "none";
|
|
49
|
-
}
|
|
50
|
-
return distance <= COMPOSER_YIELD_NEAR_ENTER_PX ? "near" : "none";
|
|
51
|
-
}
|
|
1
|
+
export * from '../../utils/composerYield';
|
|
@@ -18,6 +18,14 @@ function nextHtmlAnnId(): string {
|
|
|
18
18
|
return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function htmlCommentDraftKey(
|
|
22
|
+
text: string,
|
|
23
|
+
anchor?: HtmlElementAnchor | null,
|
|
24
|
+
targetKey?: string,
|
|
25
|
+
): string {
|
|
26
|
+
return `html-selection:${targetKey ?? JSON.stringify(anchor ?? null)}:${text}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
interface BridgeSelectionMessage {
|
|
22
30
|
type: `${typeof PREFIX}selection`;
|
|
23
31
|
text: string;
|
|
@@ -65,7 +73,31 @@ type BridgeMessage =
|
|
|
65
73
|
| { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
|
|
66
74
|
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
67
75
|
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
68
|
-
| { type: `${typeof PREFIX}
|
|
76
|
+
| { type: `${typeof PREFIX}unanchored`; ids: string[] }
|
|
77
|
+
| { type: `${typeof PREFIX}resize`; height: number }
|
|
78
|
+
| { type: `${typeof PREFIX}page-change`; pageUrl: string };
|
|
79
|
+
|
|
80
|
+
/** Live proxied-app session credentials: the proxy origin messages must come
|
|
81
|
+
* from, and the per-session token every message must echo. */
|
|
82
|
+
export interface HtmlLiveSession {
|
|
83
|
+
origin: string;
|
|
84
|
+
token: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Cap for live-mode page identity strings (mirrors the bridge's slice). */
|
|
88
|
+
export const MAX_PAGE_URL_LENGTH = 2048;
|
|
89
|
+
|
|
90
|
+
/** True when a live-session message event fails the origin or token check.
|
|
91
|
+
* Exported for protocol tests. */
|
|
92
|
+
export function rejectsLiveMessage(
|
|
93
|
+
live: HtmlLiveSession,
|
|
94
|
+
origin: string,
|
|
95
|
+
data: unknown,
|
|
96
|
+
): boolean {
|
|
97
|
+
if (origin !== live.origin) return true;
|
|
98
|
+
if (!isRecord(data) || data.token !== live.token) return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
69
101
|
|
|
70
102
|
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
|
|
71
103
|
export interface UseHtmlAnnotationOptions {
|
|
@@ -78,15 +110,44 @@ export interface UseHtmlAnnotationOptions {
|
|
|
78
110
|
selectedAnnotationId: string | null;
|
|
79
111
|
mode: EditorMode;
|
|
80
112
|
onResize?: (height: number) => void;
|
|
113
|
+
/** Live proxied-app session: reject messages that fail the origin or token
|
|
114
|
+
* check before parsing, and stamp token + concrete targetOrigin on every
|
|
115
|
+
* outbound post. Absent for srcdoc sessions (behavior unchanged). */
|
|
116
|
+
live?: HtmlLiveSession;
|
|
117
|
+
/** Live-mode page navigation reports (validated, capped at 2048 chars). */
|
|
118
|
+
onPageChange?: (pageUrl: string) => void;
|
|
81
119
|
/** Validated pointer positions relayed from inside the iframe while a
|
|
82
120
|
* pinpoint draft is open (iframe-local viewport coordinates), with the
|
|
83
121
|
* Shift state observed by the iframe (the parent cannot see modifiers
|
|
84
122
|
* held while the pointer lives in the sandbox). Drives the composer-yield
|
|
85
123
|
* fade in the host component. */
|
|
86
124
|
onBridgePointer?: (x: number, y: number, shift: boolean) => void;
|
|
125
|
+
/** Reports the full set of annotation ids that currently have NO live
|
|
126
|
+
* representation on the page — every target dead, or the restore never
|
|
127
|
+
* resolved (fail-closed anchors hide markers rather than guess). Called
|
|
128
|
+
* with the complete current set whenever it changes, including back to
|
|
129
|
+
* empty on recovery. Delivered in readOnly mode too: view-only surfaces
|
|
130
|
+
* are exactly where silently missing markers would go unnoticed. */
|
|
131
|
+
onUnanchoredChange?: (ids: string[]) => void;
|
|
87
132
|
}
|
|
88
133
|
|
|
89
|
-
function postToIframe(
|
|
134
|
+
function postToIframe(
|
|
135
|
+
iframe: HTMLIFrameElement | null,
|
|
136
|
+
msg: Record<string, unknown>,
|
|
137
|
+
live?: HtmlLiveSession | null,
|
|
138
|
+
) {
|
|
139
|
+
if (live) {
|
|
140
|
+
// Live proxied-app sessions: token on every message, concrete targetOrigin.
|
|
141
|
+
// Browsers silently DROP a post whose targetOrigin does not match the
|
|
142
|
+
// receiving window (mid-navigation frames); some DOM environments throw
|
|
143
|
+
// instead, so align with the browser semantics explicitly.
|
|
144
|
+
try {
|
|
145
|
+
iframe?.contentWindow?.postMessage({ ...msg, token: live.token }, live.origin);
|
|
146
|
+
} catch {
|
|
147
|
+
// Dropped, matching browser behavior for unmatched target origins.
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
90
151
|
iframe?.contentWindow?.postMessage(msg, "*");
|
|
91
152
|
}
|
|
92
153
|
|
|
@@ -258,10 +319,29 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
258
319
|
return typeof value.id === "string" && value.id.length <= 256
|
|
259
320
|
? { type: value.type, id: value.id }
|
|
260
321
|
: null;
|
|
322
|
+
case `${PREFIX}unanchored`: {
|
|
323
|
+
// Bounded like the bridge's own emission (512 ids, 256 chars each); any
|
|
324
|
+
// out-of-contract entry rejects the whole report — the real bridge
|
|
325
|
+
// never sends one, so a violation means a forged message.
|
|
326
|
+
if (!Array.isArray(value.ids) || value.ids.length > 512) return null;
|
|
327
|
+
const unanchoredIds: string[] = [];
|
|
328
|
+
for (const entry of value.ids) {
|
|
329
|
+
if (typeof entry !== "string" || entry.length > 256) return null;
|
|
330
|
+
unanchoredIds.push(entry);
|
|
331
|
+
}
|
|
332
|
+
return { type: value.type, ids: unanchoredIds };
|
|
333
|
+
}
|
|
261
334
|
case `${PREFIX}resize`:
|
|
262
335
|
return typeof value.height === "number" && Number.isFinite(value.height)
|
|
263
336
|
? { type: value.type, height: value.height }
|
|
264
337
|
: null;
|
|
338
|
+
case `${PREFIX}page-change`:
|
|
339
|
+
// Live-mode SPA navigation report. Bounded like every bridge string.
|
|
340
|
+
return typeof value.pageUrl === "string"
|
|
341
|
+
&& value.pageUrl.length > 0
|
|
342
|
+
&& value.pageUrl.length <= MAX_PAGE_URL_LENGTH
|
|
343
|
+
? { type: value.type, pageUrl: value.pageUrl }
|
|
344
|
+
: null;
|
|
265
345
|
default:
|
|
266
346
|
return null;
|
|
267
347
|
}
|
|
@@ -281,7 +361,10 @@ export function useHtmlAnnotation({
|
|
|
281
361
|
selectedAnnotationId,
|
|
282
362
|
mode,
|
|
283
363
|
onResize,
|
|
364
|
+
live,
|
|
365
|
+
onPageChange,
|
|
284
366
|
onBridgePointer,
|
|
367
|
+
onUnanchoredChange,
|
|
285
368
|
}: UseHtmlAnnotationOptions): Omit<
|
|
286
369
|
UseAnnotationHighlighterReturn,
|
|
287
370
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
@@ -328,9 +411,24 @@ export function useHtmlAnnotation({
|
|
|
328
411
|
onAddRef.current = onAddAnnotation;
|
|
329
412
|
const onSelectRef = useRef(onSelectAnnotation);
|
|
330
413
|
onSelectRef.current = onSelectAnnotation;
|
|
414
|
+
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
415
|
+
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
416
|
+
const liveRef = useRef<HtmlLiveSession | null>(live ?? null);
|
|
417
|
+
liveRef.current = live ?? null;
|
|
418
|
+
const onPageChangeRef = useRef(onPageChange);
|
|
419
|
+
onPageChangeRef.current = onPageChange;
|
|
331
420
|
|
|
332
421
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
333
422
|
|
|
423
|
+
/** Post into the iframe with the live token + targetOrigin when a live
|
|
424
|
+
* session is active; srcdoc posts keep targetOrigin "*" and no token. */
|
|
425
|
+
const post = useCallback(
|
|
426
|
+
(msg: Record<string, unknown>) => {
|
|
427
|
+
postToIframe(iframeRef.current, msg, liveRef.current);
|
|
428
|
+
},
|
|
429
|
+
[iframeRef],
|
|
430
|
+
);
|
|
431
|
+
|
|
334
432
|
/**
|
|
335
433
|
* Remove one draft target. Removing the primary promotes the next remaining
|
|
336
434
|
* target (the composer's context text and pending anchor follow it);
|
|
@@ -345,7 +443,7 @@ export function useHtmlAnnotation({
|
|
|
345
443
|
const targets = draftTargetsRef.current;
|
|
346
444
|
const index = targets.findIndex((t) => t.key === key);
|
|
347
445
|
if (index < 0) return;
|
|
348
|
-
|
|
446
|
+
post({ type: `${PREFIX}remove-target`, key });
|
|
349
447
|
const remaining = targets.filter((t) => t.key !== key);
|
|
350
448
|
if (remaining.length === 0) {
|
|
351
449
|
// Final target removed — the draft is cancelled (bridge side already
|
|
@@ -369,7 +467,7 @@ export function useHtmlAnnotation({
|
|
|
369
467
|
setDraftTargets(remaining);
|
|
370
468
|
setComposerFocusToken((t) => t + 1);
|
|
371
469
|
},
|
|
372
|
-
[
|
|
470
|
+
[post],
|
|
373
471
|
);
|
|
374
472
|
|
|
375
473
|
const getOrCreateAnchor = useCallback(() => {
|
|
@@ -406,12 +504,24 @@ export function useHtmlAnnotation({
|
|
|
406
504
|
useEffect(() => {
|
|
407
505
|
function handler(e: MessageEvent<unknown>) {
|
|
408
506
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
507
|
+
// Live sessions verify origin + session token BEFORE parsing. The
|
|
508
|
+
// existing caps stay exactly as they are: live content is friendlier
|
|
509
|
+
// but the trust boundary does not relax.
|
|
510
|
+
const liveSession = liveRef.current;
|
|
511
|
+
if (liveSession && rejectsLiveMessage(liveSession, e.origin, e.data)) return;
|
|
409
512
|
const message = parseBridgeMessage(e.data);
|
|
410
513
|
if (!message) return;
|
|
411
514
|
|
|
412
515
|
const type = message.type;
|
|
413
516
|
|
|
414
|
-
if (
|
|
517
|
+
if (
|
|
518
|
+
!enabledRef.current
|
|
519
|
+
&& type !== `${PREFIX}mark-click`
|
|
520
|
+
&& type !== `${PREFIX}unanchored`
|
|
521
|
+
&& type !== `${PREFIX}resize`
|
|
522
|
+
// Page identity is navigation state, not an annotation mutation.
|
|
523
|
+
&& type !== `${PREFIX}page-change`
|
|
524
|
+
) {
|
|
415
525
|
return;
|
|
416
526
|
}
|
|
417
527
|
|
|
@@ -422,25 +532,20 @@ export function useHtmlAnnotation({
|
|
|
422
532
|
const anchor = positionAnchor(message.rect);
|
|
423
533
|
if (!anchor) return;
|
|
424
534
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
htmlAnchor: message.anchor,
|
|
440
|
-
});
|
|
441
|
-
pendingTextRef.current = "";
|
|
442
|
-
pendingAnchorRef.current = null;
|
|
443
|
-
} else if (
|
|
535
|
+
// HTML and live-app surfaces are COMMENT-ONLY: redline (auto-DELETION)
|
|
536
|
+
// and quickLabel are markdown-surface features, so both the host's
|
|
537
|
+
// mode and any bridge-posted modeOverride clamp to the plain selection
|
|
538
|
+
// flow here (a hostile page can post modeOverride, so the clamp sits
|
|
539
|
+
// at the trust boundary, not in the host). Persisted DELETION
|
|
540
|
+
// annotations still restore through applyAnnotations — only CREATION
|
|
541
|
+
// is comment-only.
|
|
542
|
+
const requestedMode = message.modeOverride ?? modeRef.current;
|
|
543
|
+
const currentMode =
|
|
544
|
+
requestedMode === "redline" || requestedMode === "quickLabel"
|
|
545
|
+
? "selection"
|
|
546
|
+
: requestedMode;
|
|
547
|
+
|
|
548
|
+
if (
|
|
444
549
|
currentMode === "comment"
|
|
445
550
|
// Pinpoint click-to-pin: the click already chose the target, so skip
|
|
446
551
|
// the intermediate toolbar and go straight to the comment composer.
|
|
@@ -453,6 +558,7 @@ export function useHtmlAnnotation({
|
|
|
453
558
|
anchorEl: anchor,
|
|
454
559
|
contextText: message.text,
|
|
455
560
|
selectedText: message.text,
|
|
561
|
+
draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
|
|
456
562
|
});
|
|
457
563
|
// Pinpoint drafts arm shift-click multi-select: the clicked element
|
|
458
564
|
// becomes the primary target of the (single) draft comment. The
|
|
@@ -468,16 +574,11 @@ export function useHtmlAnnotation({
|
|
|
468
574
|
anchor: message.anchor ?? null,
|
|
469
575
|
},
|
|
470
576
|
]);
|
|
471
|
-
|
|
577
|
+
post({
|
|
472
578
|
type: `${PREFIX}arm-multi-select`,
|
|
473
579
|
key: message.targetKey,
|
|
474
580
|
});
|
|
475
581
|
}
|
|
476
|
-
} else if (currentMode === "quickLabel") {
|
|
477
|
-
setQuickLabelPicker({
|
|
478
|
-
anchorEl: anchor,
|
|
479
|
-
cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
|
|
480
|
-
});
|
|
481
582
|
} else {
|
|
482
583
|
setToolbarState({
|
|
483
584
|
element: anchor,
|
|
@@ -557,16 +658,30 @@ export function useHtmlAnnotation({
|
|
|
557
658
|
// the typing) — otherwise the iframe keeps focus and the bridge eats keys.
|
|
558
659
|
iframeRef.current?.blur();
|
|
559
660
|
setToolbarState(null);
|
|
560
|
-
setCommentPopover({
|
|
661
|
+
setCommentPopover({
|
|
662
|
+
anchorEl: anchor,
|
|
663
|
+
contextText: text,
|
|
664
|
+
selectedText: text,
|
|
665
|
+
initialText: key,
|
|
666
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
667
|
+
});
|
|
561
668
|
}
|
|
562
669
|
|
|
563
670
|
if (type === `${PREFIX}mark-click`) {
|
|
564
671
|
onSelectRef.current?.(message.id);
|
|
565
672
|
}
|
|
566
673
|
|
|
674
|
+
if (type === `${PREFIX}unanchored`) {
|
|
675
|
+
onUnanchoredChangeRef.current?.(message.ids);
|
|
676
|
+
}
|
|
677
|
+
|
|
567
678
|
if (type === `${PREFIX}resize`) {
|
|
568
679
|
onResize?.(message.height);
|
|
569
680
|
}
|
|
681
|
+
|
|
682
|
+
if (type === `${PREFIX}page-change`) {
|
|
683
|
+
onPageChangeRef.current?.(message.pageUrl);
|
|
684
|
+
}
|
|
570
685
|
}
|
|
571
686
|
|
|
572
687
|
window.addEventListener("message", handler);
|
|
@@ -577,7 +692,7 @@ export function useHtmlAnnotation({
|
|
|
577
692
|
anchorRef.current = null;
|
|
578
693
|
}
|
|
579
694
|
};
|
|
580
|
-
}, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
695
|
+
}, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
581
696
|
|
|
582
697
|
useEffect(() => {
|
|
583
698
|
if (enabled) return;
|
|
@@ -589,22 +704,22 @@ export function useHtmlAnnotation({
|
|
|
589
704
|
pendingAnchorRef.current = null;
|
|
590
705
|
anchorRef.current?.remove();
|
|
591
706
|
anchorRef.current = null;
|
|
592
|
-
|
|
593
|
-
}, [enabled,
|
|
707
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
708
|
+
}, [enabled, post]);
|
|
594
709
|
|
|
595
710
|
useEffect(() => {
|
|
596
711
|
if (selectedAnnotationId) {
|
|
597
|
-
|
|
712
|
+
post({
|
|
598
713
|
type: `${PREFIX}scroll-to`,
|
|
599
714
|
id: selectedAnnotationId,
|
|
600
715
|
});
|
|
601
716
|
} else {
|
|
602
|
-
|
|
717
|
+
post({
|
|
603
718
|
type: `${PREFIX}focus-mark`,
|
|
604
719
|
id: null,
|
|
605
720
|
});
|
|
606
721
|
}
|
|
607
|
-
}, [selectedAnnotationId,
|
|
722
|
+
}, [selectedAnnotationId, post]);
|
|
608
723
|
|
|
609
724
|
const handleAnnotate = useCallback(
|
|
610
725
|
(type: AnnotationType) => {
|
|
@@ -613,7 +728,7 @@ export function useHtmlAnnotation({
|
|
|
613
728
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
614
729
|
|
|
615
730
|
const id = nextHtmlAnnId();
|
|
616
|
-
|
|
731
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
617
732
|
onAddRef.current?.({
|
|
618
733
|
id,
|
|
619
734
|
blockId: "",
|
|
@@ -630,7 +745,7 @@ export function useHtmlAnnotation({
|
|
|
630
745
|
pendingTextRef.current = "";
|
|
631
746
|
pendingAnchorRef.current = null;
|
|
632
747
|
},
|
|
633
|
-
[
|
|
748
|
+
[post],
|
|
634
749
|
);
|
|
635
750
|
|
|
636
751
|
const handleRequestComment = useCallback(
|
|
@@ -640,7 +755,13 @@ export function useHtmlAnnotation({
|
|
|
640
755
|
if (!text) return;
|
|
641
756
|
const anchor = anchorRef.current ?? getOrCreateAnchor();
|
|
642
757
|
setToolbarState(null);
|
|
643
|
-
setCommentPopover({
|
|
758
|
+
setCommentPopover({
|
|
759
|
+
anchorEl: anchor,
|
|
760
|
+
contextText: text,
|
|
761
|
+
selectedText: text,
|
|
762
|
+
initialText: initialChar,
|
|
763
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
764
|
+
});
|
|
644
765
|
},
|
|
645
766
|
[getOrCreateAnchor],
|
|
646
767
|
);
|
|
@@ -665,7 +786,7 @@ export function useHtmlAnnotation({
|
|
|
665
786
|
: undefined;
|
|
666
787
|
|
|
667
788
|
const id = nextHtmlAnnId();
|
|
668
|
-
|
|
789
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
669
790
|
onAddRef.current?.({
|
|
670
791
|
id,
|
|
671
792
|
blockId: "",
|
|
@@ -686,16 +807,16 @@ export function useHtmlAnnotation({
|
|
|
686
807
|
pendingTextRef.current = "";
|
|
687
808
|
pendingAnchorRef.current = null;
|
|
688
809
|
},
|
|
689
|
-
[
|
|
810
|
+
[post],
|
|
690
811
|
);
|
|
691
812
|
|
|
692
813
|
const handleCommentClose = useCallback(() => {
|
|
693
|
-
|
|
814
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
694
815
|
setCommentPopover(null);
|
|
695
816
|
setDraftTargets([]);
|
|
696
817
|
pendingTextRef.current = "";
|
|
697
818
|
pendingAnchorRef.current = null;
|
|
698
|
-
}, [
|
|
819
|
+
}, [post]);
|
|
699
820
|
|
|
700
821
|
const removeDraftTarget = useCallback(
|
|
701
822
|
(key: string) => {
|
|
@@ -707,17 +828,17 @@ export function useHtmlAnnotation({
|
|
|
707
828
|
|
|
708
829
|
const flashDraftTarget = useCallback(
|
|
709
830
|
(key: string) => {
|
|
710
|
-
|
|
831
|
+
post({ type: `${PREFIX}flash-target`, key });
|
|
711
832
|
},
|
|
712
|
-
[
|
|
833
|
+
[post],
|
|
713
834
|
);
|
|
714
835
|
|
|
715
836
|
const handleToolbarClose = useCallback(() => {
|
|
716
|
-
|
|
837
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
717
838
|
setToolbarState(null);
|
|
718
839
|
pendingTextRef.current = "";
|
|
719
840
|
pendingAnchorRef.current = null;
|
|
720
|
-
}, [
|
|
841
|
+
}, [post]);
|
|
721
842
|
|
|
722
843
|
const applyQuickLabel = useCallback(
|
|
723
844
|
(label: QuickLabel, clearState: () => void) => {
|
|
@@ -725,7 +846,7 @@ export function useHtmlAnnotation({
|
|
|
725
846
|
const text = pendingTextRef.current;
|
|
726
847
|
if (!text) return;
|
|
727
848
|
const id = nextHtmlAnnId();
|
|
728
|
-
|
|
849
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
729
850
|
onAddRef.current?.({
|
|
730
851
|
id,
|
|
731
852
|
blockId: "",
|
|
@@ -744,7 +865,7 @@ export function useHtmlAnnotation({
|
|
|
744
865
|
pendingTextRef.current = "";
|
|
745
866
|
pendingAnchorRef.current = null;
|
|
746
867
|
},
|
|
747
|
-
[
|
|
868
|
+
[post],
|
|
748
869
|
);
|
|
749
870
|
|
|
750
871
|
const handleQuickLabel = useCallback(
|
|
@@ -758,22 +879,22 @@ export function useHtmlAnnotation({
|
|
|
758
879
|
);
|
|
759
880
|
|
|
760
881
|
const handleQuickLabelPickerDismiss = useCallback(() => {
|
|
761
|
-
|
|
882
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
762
883
|
setQuickLabelPicker(null);
|
|
763
884
|
pendingTextRef.current = "";
|
|
764
885
|
pendingAnchorRef.current = null;
|
|
765
|
-
}, [
|
|
886
|
+
}, [post]);
|
|
766
887
|
|
|
767
888
|
const removeHighlight = useCallback(
|
|
768
889
|
(id: string) => {
|
|
769
|
-
|
|
890
|
+
post({ type: `${PREFIX}remove-mark`, id });
|
|
770
891
|
},
|
|
771
|
-
[
|
|
892
|
+
[post],
|
|
772
893
|
);
|
|
773
894
|
|
|
774
895
|
const clearAllHighlights = useCallback(() => {
|
|
775
|
-
|
|
776
|
-
}, [
|
|
896
|
+
post({ type: `${PREFIX}clear-marks` });
|
|
897
|
+
}, [post]);
|
|
777
898
|
|
|
778
899
|
const applyAnnotations = useCallback(
|
|
779
900
|
(anns: Annotation[]) => {
|
|
@@ -786,7 +907,7 @@ export function useHtmlAnnotation({
|
|
|
786
907
|
.map((t) => t.anchor)
|
|
787
908
|
.filter((a): a is HtmlElementAnchor => !!a)
|
|
788
909
|
.slice(0, MAX_ADDITIONAL_TARGETS);
|
|
789
|
-
|
|
910
|
+
post({
|
|
790
911
|
type: `${PREFIX}find-and-mark`,
|
|
791
912
|
id: ann.id,
|
|
792
913
|
originalText: ann.originalText,
|
|
@@ -798,7 +919,7 @@ export function useHtmlAnnotation({
|
|
|
798
919
|
});
|
|
799
920
|
}
|
|
800
921
|
},
|
|
801
|
-
[
|
|
922
|
+
[post],
|
|
802
923
|
);
|
|
803
924
|
|
|
804
925
|
return {
|
|
@@ -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>
|