@plannotator/ui 0.30.0 → 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 +62 -32
- 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 +208 -39
- package/components/html-viewer/bridge-script.ts +319 -65
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +146 -57
- 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;
|
|
@@ -66,7 +74,30 @@ type BridgeMessage =
|
|
|
66
74
|
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
67
75
|
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
68
76
|
| { type: `${typeof PREFIX}unanchored`; ids: string[] }
|
|
69
|
-
| { type: `${typeof PREFIX}resize`; height: number }
|
|
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
|
+
}
|
|
70
101
|
|
|
71
102
|
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
|
|
72
103
|
export interface UseHtmlAnnotationOptions {
|
|
@@ -79,6 +110,12 @@ export interface UseHtmlAnnotationOptions {
|
|
|
79
110
|
selectedAnnotationId: string | null;
|
|
80
111
|
mode: EditorMode;
|
|
81
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;
|
|
82
119
|
/** Validated pointer positions relayed from inside the iframe while a
|
|
83
120
|
* pinpoint draft is open (iframe-local viewport coordinates), with the
|
|
84
121
|
* Shift state observed by the iframe (the parent cannot see modifiers
|
|
@@ -94,7 +131,23 @@ export interface UseHtmlAnnotationOptions {
|
|
|
94
131
|
onUnanchoredChange?: (ids: string[]) => void;
|
|
95
132
|
}
|
|
96
133
|
|
|
97
|
-
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
|
+
}
|
|
98
151
|
iframe?.contentWindow?.postMessage(msg, "*");
|
|
99
152
|
}
|
|
100
153
|
|
|
@@ -282,6 +335,13 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
282
335
|
return typeof value.height === "number" && Number.isFinite(value.height)
|
|
283
336
|
? { type: value.type, height: value.height }
|
|
284
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;
|
|
285
345
|
default:
|
|
286
346
|
return null;
|
|
287
347
|
}
|
|
@@ -301,6 +361,8 @@ export function useHtmlAnnotation({
|
|
|
301
361
|
selectedAnnotationId,
|
|
302
362
|
mode,
|
|
303
363
|
onResize,
|
|
364
|
+
live,
|
|
365
|
+
onPageChange,
|
|
304
366
|
onBridgePointer,
|
|
305
367
|
onUnanchoredChange,
|
|
306
368
|
}: UseHtmlAnnotationOptions): Omit<
|
|
@@ -351,9 +413,22 @@ export function useHtmlAnnotation({
|
|
|
351
413
|
onSelectRef.current = onSelectAnnotation;
|
|
352
414
|
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
353
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;
|
|
354
420
|
|
|
355
421
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
356
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
|
+
|
|
357
432
|
/**
|
|
358
433
|
* Remove one draft target. Removing the primary promotes the next remaining
|
|
359
434
|
* target (the composer's context text and pending anchor follow it);
|
|
@@ -368,7 +443,7 @@ export function useHtmlAnnotation({
|
|
|
368
443
|
const targets = draftTargetsRef.current;
|
|
369
444
|
const index = targets.findIndex((t) => t.key === key);
|
|
370
445
|
if (index < 0) return;
|
|
371
|
-
|
|
446
|
+
post({ type: `${PREFIX}remove-target`, key });
|
|
372
447
|
const remaining = targets.filter((t) => t.key !== key);
|
|
373
448
|
if (remaining.length === 0) {
|
|
374
449
|
// Final target removed — the draft is cancelled (bridge side already
|
|
@@ -392,7 +467,7 @@ export function useHtmlAnnotation({
|
|
|
392
467
|
setDraftTargets(remaining);
|
|
393
468
|
setComposerFocusToken((t) => t + 1);
|
|
394
469
|
},
|
|
395
|
-
[
|
|
470
|
+
[post],
|
|
396
471
|
);
|
|
397
472
|
|
|
398
473
|
const getOrCreateAnchor = useCallback(() => {
|
|
@@ -429,6 +504,11 @@ export function useHtmlAnnotation({
|
|
|
429
504
|
useEffect(() => {
|
|
430
505
|
function handler(e: MessageEvent<unknown>) {
|
|
431
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;
|
|
432
512
|
const message = parseBridgeMessage(e.data);
|
|
433
513
|
if (!message) return;
|
|
434
514
|
|
|
@@ -439,6 +519,8 @@ export function useHtmlAnnotation({
|
|
|
439
519
|
&& type !== `${PREFIX}mark-click`
|
|
440
520
|
&& type !== `${PREFIX}unanchored`
|
|
441
521
|
&& type !== `${PREFIX}resize`
|
|
522
|
+
// Page identity is navigation state, not an annotation mutation.
|
|
523
|
+
&& type !== `${PREFIX}page-change`
|
|
442
524
|
) {
|
|
443
525
|
return;
|
|
444
526
|
}
|
|
@@ -450,25 +532,20 @@ export function useHtmlAnnotation({
|
|
|
450
532
|
const anchor = positionAnchor(message.rect);
|
|
451
533
|
if (!anchor) return;
|
|
452
534
|
|
|
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 (
|
|
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 (
|
|
472
549
|
currentMode === "comment"
|
|
473
550
|
// Pinpoint click-to-pin: the click already chose the target, so skip
|
|
474
551
|
// the intermediate toolbar and go straight to the comment composer.
|
|
@@ -481,6 +558,7 @@ export function useHtmlAnnotation({
|
|
|
481
558
|
anchorEl: anchor,
|
|
482
559
|
contextText: message.text,
|
|
483
560
|
selectedText: message.text,
|
|
561
|
+
draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
|
|
484
562
|
});
|
|
485
563
|
// Pinpoint drafts arm shift-click multi-select: the clicked element
|
|
486
564
|
// becomes the primary target of the (single) draft comment. The
|
|
@@ -496,16 +574,11 @@ export function useHtmlAnnotation({
|
|
|
496
574
|
anchor: message.anchor ?? null,
|
|
497
575
|
},
|
|
498
576
|
]);
|
|
499
|
-
|
|
577
|
+
post({
|
|
500
578
|
type: `${PREFIX}arm-multi-select`,
|
|
501
579
|
key: message.targetKey,
|
|
502
580
|
});
|
|
503
581
|
}
|
|
504
|
-
} else if (currentMode === "quickLabel") {
|
|
505
|
-
setQuickLabelPicker({
|
|
506
|
-
anchorEl: anchor,
|
|
507
|
-
cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
|
|
508
|
-
});
|
|
509
582
|
} else {
|
|
510
583
|
setToolbarState({
|
|
511
584
|
element: anchor,
|
|
@@ -585,7 +658,13 @@ export function useHtmlAnnotation({
|
|
|
585
658
|
// the typing) — otherwise the iframe keeps focus and the bridge eats keys.
|
|
586
659
|
iframeRef.current?.blur();
|
|
587
660
|
setToolbarState(null);
|
|
588
|
-
setCommentPopover({
|
|
661
|
+
setCommentPopover({
|
|
662
|
+
anchorEl: anchor,
|
|
663
|
+
contextText: text,
|
|
664
|
+
selectedText: text,
|
|
665
|
+
initialText: key,
|
|
666
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
667
|
+
});
|
|
589
668
|
}
|
|
590
669
|
|
|
591
670
|
if (type === `${PREFIX}mark-click`) {
|
|
@@ -599,6 +678,10 @@ export function useHtmlAnnotation({
|
|
|
599
678
|
if (type === `${PREFIX}resize`) {
|
|
600
679
|
onResize?.(message.height);
|
|
601
680
|
}
|
|
681
|
+
|
|
682
|
+
if (type === `${PREFIX}page-change`) {
|
|
683
|
+
onPageChangeRef.current?.(message.pageUrl);
|
|
684
|
+
}
|
|
602
685
|
}
|
|
603
686
|
|
|
604
687
|
window.addEventListener("message", handler);
|
|
@@ -609,7 +692,7 @@ export function useHtmlAnnotation({
|
|
|
609
692
|
anchorRef.current = null;
|
|
610
693
|
}
|
|
611
694
|
};
|
|
612
|
-
}, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
695
|
+
}, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
613
696
|
|
|
614
697
|
useEffect(() => {
|
|
615
698
|
if (enabled) return;
|
|
@@ -621,22 +704,22 @@ export function useHtmlAnnotation({
|
|
|
621
704
|
pendingAnchorRef.current = null;
|
|
622
705
|
anchorRef.current?.remove();
|
|
623
706
|
anchorRef.current = null;
|
|
624
|
-
|
|
625
|
-
}, [enabled,
|
|
707
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
708
|
+
}, [enabled, post]);
|
|
626
709
|
|
|
627
710
|
useEffect(() => {
|
|
628
711
|
if (selectedAnnotationId) {
|
|
629
|
-
|
|
712
|
+
post({
|
|
630
713
|
type: `${PREFIX}scroll-to`,
|
|
631
714
|
id: selectedAnnotationId,
|
|
632
715
|
});
|
|
633
716
|
} else {
|
|
634
|
-
|
|
717
|
+
post({
|
|
635
718
|
type: `${PREFIX}focus-mark`,
|
|
636
719
|
id: null,
|
|
637
720
|
});
|
|
638
721
|
}
|
|
639
|
-
}, [selectedAnnotationId,
|
|
722
|
+
}, [selectedAnnotationId, post]);
|
|
640
723
|
|
|
641
724
|
const handleAnnotate = useCallback(
|
|
642
725
|
(type: AnnotationType) => {
|
|
@@ -645,7 +728,7 @@ export function useHtmlAnnotation({
|
|
|
645
728
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
646
729
|
|
|
647
730
|
const id = nextHtmlAnnId();
|
|
648
|
-
|
|
731
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
|
|
649
732
|
onAddRef.current?.({
|
|
650
733
|
id,
|
|
651
734
|
blockId: "",
|
|
@@ -662,7 +745,7 @@ export function useHtmlAnnotation({
|
|
|
662
745
|
pendingTextRef.current = "";
|
|
663
746
|
pendingAnchorRef.current = null;
|
|
664
747
|
},
|
|
665
|
-
[
|
|
748
|
+
[post],
|
|
666
749
|
);
|
|
667
750
|
|
|
668
751
|
const handleRequestComment = useCallback(
|
|
@@ -672,7 +755,13 @@ export function useHtmlAnnotation({
|
|
|
672
755
|
if (!text) return;
|
|
673
756
|
const anchor = anchorRef.current ?? getOrCreateAnchor();
|
|
674
757
|
setToolbarState(null);
|
|
675
|
-
setCommentPopover({
|
|
758
|
+
setCommentPopover({
|
|
759
|
+
anchorEl: anchor,
|
|
760
|
+
contextText: text,
|
|
761
|
+
selectedText: text,
|
|
762
|
+
initialText: initialChar,
|
|
763
|
+
draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
|
|
764
|
+
});
|
|
676
765
|
},
|
|
677
766
|
[getOrCreateAnchor],
|
|
678
767
|
);
|
|
@@ -697,7 +786,7 @@ export function useHtmlAnnotation({
|
|
|
697
786
|
: undefined;
|
|
698
787
|
|
|
699
788
|
const id = nextHtmlAnnId();
|
|
700
|
-
|
|
789
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
701
790
|
onAddRef.current?.({
|
|
702
791
|
id,
|
|
703
792
|
blockId: "",
|
|
@@ -718,16 +807,16 @@ export function useHtmlAnnotation({
|
|
|
718
807
|
pendingTextRef.current = "";
|
|
719
808
|
pendingAnchorRef.current = null;
|
|
720
809
|
},
|
|
721
|
-
[
|
|
810
|
+
[post],
|
|
722
811
|
);
|
|
723
812
|
|
|
724
813
|
const handleCommentClose = useCallback(() => {
|
|
725
|
-
|
|
814
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
726
815
|
setCommentPopover(null);
|
|
727
816
|
setDraftTargets([]);
|
|
728
817
|
pendingTextRef.current = "";
|
|
729
818
|
pendingAnchorRef.current = null;
|
|
730
|
-
}, [
|
|
819
|
+
}, [post]);
|
|
731
820
|
|
|
732
821
|
const removeDraftTarget = useCallback(
|
|
733
822
|
(key: string) => {
|
|
@@ -739,17 +828,17 @@ export function useHtmlAnnotation({
|
|
|
739
828
|
|
|
740
829
|
const flashDraftTarget = useCallback(
|
|
741
830
|
(key: string) => {
|
|
742
|
-
|
|
831
|
+
post({ type: `${PREFIX}flash-target`, key });
|
|
743
832
|
},
|
|
744
|
-
[
|
|
833
|
+
[post],
|
|
745
834
|
);
|
|
746
835
|
|
|
747
836
|
const handleToolbarClose = useCallback(() => {
|
|
748
|
-
|
|
837
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
749
838
|
setToolbarState(null);
|
|
750
839
|
pendingTextRef.current = "";
|
|
751
840
|
pendingAnchorRef.current = null;
|
|
752
|
-
}, [
|
|
841
|
+
}, [post]);
|
|
753
842
|
|
|
754
843
|
const applyQuickLabel = useCallback(
|
|
755
844
|
(label: QuickLabel, clearState: () => void) => {
|
|
@@ -757,7 +846,7 @@ export function useHtmlAnnotation({
|
|
|
757
846
|
const text = pendingTextRef.current;
|
|
758
847
|
if (!text) return;
|
|
759
848
|
const id = nextHtmlAnnId();
|
|
760
|
-
|
|
849
|
+
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
761
850
|
onAddRef.current?.({
|
|
762
851
|
id,
|
|
763
852
|
blockId: "",
|
|
@@ -776,7 +865,7 @@ export function useHtmlAnnotation({
|
|
|
776
865
|
pendingTextRef.current = "";
|
|
777
866
|
pendingAnchorRef.current = null;
|
|
778
867
|
},
|
|
779
|
-
[
|
|
868
|
+
[post],
|
|
780
869
|
);
|
|
781
870
|
|
|
782
871
|
const handleQuickLabel = useCallback(
|
|
@@ -790,22 +879,22 @@ export function useHtmlAnnotation({
|
|
|
790
879
|
);
|
|
791
880
|
|
|
792
881
|
const handleQuickLabelPickerDismiss = useCallback(() => {
|
|
793
|
-
|
|
882
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
794
883
|
setQuickLabelPicker(null);
|
|
795
884
|
pendingTextRef.current = "";
|
|
796
885
|
pendingAnchorRef.current = null;
|
|
797
|
-
}, [
|
|
886
|
+
}, [post]);
|
|
798
887
|
|
|
799
888
|
const removeHighlight = useCallback(
|
|
800
889
|
(id: string) => {
|
|
801
|
-
|
|
890
|
+
post({ type: `${PREFIX}remove-mark`, id });
|
|
802
891
|
},
|
|
803
|
-
[
|
|
892
|
+
[post],
|
|
804
893
|
);
|
|
805
894
|
|
|
806
895
|
const clearAllHighlights = useCallback(() => {
|
|
807
|
-
|
|
808
|
-
}, [
|
|
896
|
+
post({ type: `${PREFIX}clear-marks` });
|
|
897
|
+
}, [post]);
|
|
809
898
|
|
|
810
899
|
const applyAnnotations = useCallback(
|
|
811
900
|
(anns: Annotation[]) => {
|
|
@@ -818,7 +907,7 @@ export function useHtmlAnnotation({
|
|
|
818
907
|
.map((t) => t.anchor)
|
|
819
908
|
.filter((a): a is HtmlElementAnchor => !!a)
|
|
820
909
|
.slice(0, MAX_ADDITIONAL_TARGETS);
|
|
821
|
-
|
|
910
|
+
post({
|
|
822
911
|
type: `${PREFIX}find-and-mark`,
|
|
823
912
|
id: ann.id,
|
|
824
913
|
originalText: ann.originalText,
|
|
@@ -830,7 +919,7 @@ export function useHtmlAnnotation({
|
|
|
830
919
|
});
|
|
831
920
|
}
|
|
832
921
|
},
|
|
833
|
-
[
|
|
922
|
+
[post],
|
|
834
923
|
);
|
|
835
924
|
|
|
836
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>
|