@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
|
@@ -25,6 +25,11 @@ interface AnnotationToolstripProps {
|
|
|
25
25
|
* narrow and leaves room for the diff badges.
|
|
26
26
|
*/
|
|
27
27
|
iconOnly?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Hide the drag/pinpoint input group entirely. Live app sessions are
|
|
30
|
+
* pinpoint-only, so the switch would be a dead control there.
|
|
31
|
+
*/
|
|
32
|
+
hideInputMethodSwitch?: boolean;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
@@ -36,6 +41,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
36
41
|
compact = false,
|
|
37
42
|
showHelpLink = true,
|
|
38
43
|
iconOnly = false,
|
|
44
|
+
hideInputMethodSwitch = false,
|
|
39
45
|
}) => {
|
|
40
46
|
const [showHelp, setShowHelp] = useState(false);
|
|
41
47
|
const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
|
|
@@ -50,6 +56,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
50
56
|
<>
|
|
51
57
|
<div className={`flex items-center flex-wrap ${compact ? 'gap-1' : 'gap-1.5'}`}>
|
|
52
58
|
{/* Input method group */}
|
|
59
|
+
{!hideInputMethodSwitch && (
|
|
53
60
|
<div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
|
|
54
61
|
<ToolstripButton
|
|
55
62
|
active={inputMethod === 'drag'}
|
|
@@ -89,6 +96,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
89
96
|
}
|
|
90
97
|
/>
|
|
91
98
|
</div>
|
|
99
|
+
)}
|
|
92
100
|
|
|
93
101
|
{/* Action mode group */}
|
|
94
102
|
<div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
|
|
@@ -321,6 +329,7 @@ const ToolstripButton: React.FC<{
|
|
|
321
329
|
onClick={onClick}
|
|
322
330
|
onMouseEnter={() => setHovered(true)}
|
|
323
331
|
onMouseLeave={() => setHovered(false)}
|
|
332
|
+
aria-pressed={active}
|
|
324
333
|
className={`relative flex items-center h-7 rounded-md overflow-hidden ${colorClass}`}
|
|
325
334
|
style={{ width: currentWidth, transition }}
|
|
326
335
|
>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useId } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { ImageAttachment } from '../types';
|
|
4
4
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
@@ -9,6 +9,12 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
|
9
9
|
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
10
10
|
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
11
11
|
import type { SkillReferenceToken } from '../utils/skillReferences';
|
|
12
|
+
import {
|
|
13
|
+
hasPrimaryCoarsePointer,
|
|
14
|
+
shouldUseExpandedComposer,
|
|
15
|
+
useVisibleViewportBounds,
|
|
16
|
+
type VisibleViewportBounds,
|
|
17
|
+
} from '../hooks/useViewportEnvironment';
|
|
12
18
|
|
|
13
19
|
export interface CommentAskAIContext {
|
|
14
20
|
kind: 'general' | 'selection';
|
|
@@ -47,6 +53,14 @@ interface CommentPopoverProps {
|
|
|
47
53
|
initialText?: string;
|
|
48
54
|
/** Called on submit with comment text and optional images */
|
|
49
55
|
onSubmit: (text: string, images?: ImageAttachment[]) => void;
|
|
56
|
+
/**
|
|
57
|
+
* One-click "Looks good" action (comment-only HTML/live surfaces, where
|
|
58
|
+
* pinpoint clicks open this composer directly and never see the selection
|
|
59
|
+
* toolbar's 👍). Renders a thumbs-up button in the footer; disabled once
|
|
60
|
+
* the user has typed or attached anything, so a click can never discard a
|
|
61
|
+
* draft. The parent owns annotation creation and closing.
|
|
62
|
+
*/
|
|
63
|
+
onQuickLookGood?: () => void;
|
|
50
64
|
/** Optional live draft observer for submit paths outside the popover. */
|
|
51
65
|
onDraftChange?: (text: string, images?: ImageAttachment[]) => void;
|
|
52
66
|
/** Called when popover is closed/cancelled */
|
|
@@ -99,19 +113,40 @@ function useCommentDraftSync(draftKey: string | undefined, text: string, images:
|
|
|
99
113
|
}, [draftKey, text, images]);
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
interface CommentPopoverPosition {
|
|
117
|
+
top: number;
|
|
118
|
+
left: number;
|
|
119
|
+
flipAbove: boolean;
|
|
120
|
+
width: number;
|
|
121
|
+
maxHeight: number;
|
|
122
|
+
requiresExpanded: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function computeCommentPopoverPosition(
|
|
126
|
+
anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
|
|
127
|
+
bounds: VisibleViewportBounds,
|
|
128
|
+
): CommentPopoverPosition {
|
|
129
|
+
const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
|
|
130
|
+
const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
|
|
131
|
+
const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
|
|
132
|
+
const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
|
|
106
133
|
|
|
107
134
|
const top = flipAbove
|
|
108
135
|
? anchorRect.top - GAP
|
|
109
136
|
: anchorRect.bottom + GAP;
|
|
110
137
|
|
|
111
138
|
let left = anchorRect.left + anchorRect.width / 2 - width / 2;
|
|
112
|
-
left = Math.max(
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
left = Math.max(bounds.left, Math.min(left, bounds.right - width));
|
|
140
|
+
const maxHeight = flipAbove ? spaceAbove : spaceBelow;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
top,
|
|
144
|
+
left,
|
|
145
|
+
flipAbove,
|
|
146
|
+
width,
|
|
147
|
+
maxHeight,
|
|
148
|
+
requiresExpanded: maxHeight < 280,
|
|
149
|
+
};
|
|
115
150
|
}
|
|
116
151
|
|
|
117
152
|
export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
@@ -121,6 +156,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
121
156
|
isGlobal,
|
|
122
157
|
initialText = '',
|
|
123
158
|
onSubmit,
|
|
159
|
+
onQuickLookGood,
|
|
124
160
|
onDraftChange,
|
|
125
161
|
onClose,
|
|
126
162
|
draftKey,
|
|
@@ -137,11 +173,27 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
137
173
|
captureStrayKeys = false,
|
|
138
174
|
yieldState,
|
|
139
175
|
}) => {
|
|
140
|
-
const
|
|
176
|
+
const visibleBounds = useVisibleViewportBounds(16);
|
|
177
|
+
const coarsePointer = hasPrimaryCoarsePointer();
|
|
178
|
+
const prefersExpandedComposer = shouldUseExpandedComposer({
|
|
179
|
+
bounds: visibleBounds,
|
|
180
|
+
coarsePointer,
|
|
181
|
+
});
|
|
182
|
+
const [mode, setMode] = useState<'popover' | 'dialog'>(() =>
|
|
183
|
+
prefersExpandedComposer ? 'dialog' : 'popover'
|
|
184
|
+
);
|
|
185
|
+
// Dialog mode has two very different origins. Either the viewport PREFERS an
|
|
186
|
+
// expanded composer (phones, small windows), or the anchor simply has no room
|
|
187
|
+
// for a popover and the geometry FORCED it. Only the forced kind can bounce:
|
|
188
|
+
// collapsing recomputes the same geometry and immediately re-expands, which
|
|
189
|
+
// ate Escape and made the Collapse button a no-op. Track which one we are in.
|
|
190
|
+
const [dialogIsForced, setDialogIsForced] = useState(false);
|
|
191
|
+
// A preference-driven dialog is never the forced kind.
|
|
192
|
+
const forcedDialog = dialogIsForced && !prefersExpandedComposer;
|
|
141
193
|
const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
|
|
142
194
|
const [text, setText] = useState(initialDraft?.text ?? initialText);
|
|
143
195
|
const [images, setImages] = useState<ImageAttachment[]>(allowImages ? initialDraft?.images ?? [] : []);
|
|
144
|
-
const [position, setPosition] = useState<
|
|
196
|
+
const [position, setPosition] = useState<CommentPopoverPosition | null>(null);
|
|
145
197
|
// Direction of an open popover that has scrolled out of view, or null when on-screen.
|
|
146
198
|
const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null);
|
|
147
199
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -150,6 +202,18 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
150
202
|
const hasUnsavedContentRef = useRef(hasUnsavedContent);
|
|
151
203
|
hasUnsavedContentRef.current = hasUnsavedContent;
|
|
152
204
|
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
|
|
205
|
+
const openingFocusRef = useRef<HTMLElement | null>(
|
|
206
|
+
typeof document !== 'undefined'
|
|
207
|
+
&& document.activeElement instanceof HTMLElement
|
|
208
|
+
&& document.activeElement !== document.body
|
|
209
|
+
&& document.activeElement !== document.documentElement
|
|
210
|
+
? document.activeElement
|
|
211
|
+
: anchorEl ?? null,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (prefersExpandedComposer && mode === 'popover') setMode('dialog');
|
|
216
|
+
}, [mode, prefersExpandedComposer]);
|
|
153
217
|
|
|
154
218
|
useEffect(() => {
|
|
155
219
|
const nextDraft = draftKey ? draftStore.get(draftKey) : undefined;
|
|
@@ -167,23 +231,29 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
167
231
|
useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
|
|
168
232
|
useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
|
|
169
233
|
|
|
170
|
-
// Track anchor position on scroll
|
|
234
|
+
// Track anchor position on scroll and observed viewport changes (popover
|
|
235
|
+
// mode only, not after user drag).
|
|
171
236
|
useEffect(() => {
|
|
172
237
|
if (mode !== 'popover' || wasDragged) return;
|
|
173
238
|
|
|
174
239
|
const update = () => {
|
|
175
240
|
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
|
|
176
|
-
if (rect)
|
|
241
|
+
if (!rect) return;
|
|
242
|
+
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
|
|
243
|
+
if (nextPosition.requiresExpanded) {
|
|
244
|
+
setDialogIsForced(true);
|
|
245
|
+
setMode('dialog');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
setPosition(nextPosition);
|
|
177
249
|
};
|
|
178
250
|
|
|
179
251
|
update();
|
|
180
252
|
window.addEventListener('scroll', update, true);
|
|
181
|
-
window.addEventListener('resize', update);
|
|
182
253
|
return () => {
|
|
183
254
|
window.removeEventListener('scroll', update, true);
|
|
184
|
-
window.removeEventListener('resize', update);
|
|
185
255
|
};
|
|
186
|
-
}, [anchorEl, anchorRect, mode, wasDragged]);
|
|
256
|
+
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
|
|
187
257
|
|
|
188
258
|
// Surface a "jump back" arrow when an open popover scrolls out of view.
|
|
189
259
|
// Re-measures whenever the popover repositions (position updates every scroll
|
|
@@ -194,14 +264,12 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
194
264
|
const el = popoverRef.current;
|
|
195
265
|
if (!el) return;
|
|
196
266
|
const rect = el.getBoundingClientRect();
|
|
197
|
-
if (rect.bottom <
|
|
198
|
-
else if (rect.top >
|
|
267
|
+
if (rect.bottom < visibleBounds.top) setOffscreen('above');
|
|
268
|
+
else if (rect.top > visibleBounds.bottom) setOffscreen('below');
|
|
199
269
|
else setOffscreen(null);
|
|
200
270
|
};
|
|
201
271
|
measure();
|
|
202
|
-
|
|
203
|
-
return () => window.removeEventListener('resize', measure);
|
|
204
|
-
}, [position, dragPosition, mode]);
|
|
272
|
+
}, [position, dragPosition, mode, visibleBounds]);
|
|
205
273
|
|
|
206
274
|
const scrollToPopover = useCallback(() => {
|
|
207
275
|
anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
@@ -212,19 +280,56 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
212
280
|
// renders after `position` is measured, and WebKit fires 0ms timers ahead of
|
|
213
281
|
// that commit, so an effect keyed on mode alone can run before the textarea
|
|
214
282
|
// exists and never focus it (e.g. in WKWebView hosts like Glimpse).
|
|
283
|
+
const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
|
|
215
284
|
const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
216
285
|
textareaRef.current = el;
|
|
217
|
-
if (!el) return;
|
|
286
|
+
if (!el || !shouldAutoFocus) return;
|
|
218
287
|
setTimeout(() => {
|
|
219
288
|
if (!el.isConnected) return;
|
|
220
289
|
el.focus();
|
|
221
290
|
el.selectionStart = el.selectionEnd = el.value.length;
|
|
222
291
|
}, 0);
|
|
292
|
+
}, [shouldAutoFocus]);
|
|
293
|
+
|
|
294
|
+
// A touch-opened composer deliberately does not focus the textarea (and
|
|
295
|
+
// summon the software keyboard), but hardware-keyboard users still need a
|
|
296
|
+
// coherent focus target and Escape dismissal.
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (mode !== 'dialog' || shouldAutoFocus) return;
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
if (!popoverRef.current?.isConnected) return;
|
|
301
|
+
popoverRef.current.focus({ preventScroll: true });
|
|
302
|
+
}, 0);
|
|
303
|
+
return () => clearTimeout(timer);
|
|
304
|
+
}, [mode, shouldAutoFocus]);
|
|
305
|
+
|
|
306
|
+
const restoreOpeningFocus = useCallback(() => {
|
|
307
|
+
const target = openingFocusRef.current;
|
|
308
|
+
requestAnimationFrame(() => {
|
|
309
|
+
if (!target?.isConnected) return;
|
|
310
|
+
target.focus({ preventScroll: true });
|
|
311
|
+
});
|
|
223
312
|
}, []);
|
|
224
313
|
|
|
314
|
+
const handleClose = useCallback(
|
|
315
|
+
(focusDisposition: 'restore-opener' | 'preserve-pointer-target' = 'restore-opener') => {
|
|
316
|
+
if (draftKey) {
|
|
317
|
+
if (hasUnsavedCommentContent(text, allowImages ? images : [])) {
|
|
318
|
+
draftStore.set(draftKey, { text, images: allowImages ? images : [] });
|
|
319
|
+
} else {
|
|
320
|
+
draftStore.delete(draftKey);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
onClose();
|
|
324
|
+
if (focusDisposition === 'restore-opener') restoreOpeningFocus();
|
|
325
|
+
},
|
|
326
|
+
[allowImages, draftKey, images, onClose, restoreOpeningFocus, text],
|
|
327
|
+
);
|
|
328
|
+
|
|
225
329
|
// Click-outside for popover mode
|
|
226
330
|
useEffect(() => {
|
|
227
331
|
if (mode !== 'popover') return;
|
|
332
|
+
const shiftSelectionActive = Boolean(targetChips?.length);
|
|
228
333
|
|
|
229
334
|
const handlePointerDown = (e: PointerEvent) => {
|
|
230
335
|
const target = e.target as Node | null;
|
|
@@ -234,12 +339,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
234
339
|
const el = target as HTMLElement;
|
|
235
340
|
if (el.closest?.('[data-popover-layer]')) return;
|
|
236
341
|
if (hasUnsavedContentRef.current) return;
|
|
237
|
-
|
|
342
|
+
// A same-document multi-select target receives pointerdown before click.
|
|
343
|
+
// Preserve the existing draft so the following Shift-click can extend
|
|
344
|
+
// it instead of silently replacing it with a new one.
|
|
345
|
+
if (shiftSelectionActive && e.shiftKey) return;
|
|
346
|
+
handleClose('preserve-pointer-target');
|
|
238
347
|
};
|
|
239
348
|
|
|
240
349
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
241
350
|
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
242
|
-
}, [mode,
|
|
351
|
+
}, [handleClose, mode, targetChips?.length]);
|
|
243
352
|
|
|
244
353
|
// Focus choreography (multi-select): after a shift-click adds/removes a
|
|
245
354
|
// target, focus returns to the textarea so typing continues uninterrupted.
|
|
@@ -347,8 +456,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
347
456
|
if (hasUnsavedContent || canSubmitEmpty) {
|
|
348
457
|
if (draftKey) draftStore.delete(draftKey);
|
|
349
458
|
onSubmit(text, allowImages && images.length > 0 ? images : undefined);
|
|
459
|
+
restoreOpeningFocus();
|
|
350
460
|
}
|
|
351
|
-
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent]);
|
|
461
|
+
}, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
|
|
352
462
|
|
|
353
463
|
const handleAskAI = useCallback(async () => {
|
|
354
464
|
const question = text.trim();
|
|
@@ -382,15 +492,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
382
492
|
textareaRef,
|
|
383
493
|
enabled: skillReferences,
|
|
384
494
|
});
|
|
495
|
+
const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
|
|
496
|
+
const activeSkillOptionId =
|
|
497
|
+
skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
|
|
498
|
+
? undefined
|
|
499
|
+
: `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
|
|
385
500
|
|
|
386
501
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
387
502
|
if (skillAc.onKeyDown(e)) return;
|
|
388
503
|
if (e.key === 'Escape') {
|
|
389
504
|
e.stopPropagation();
|
|
390
505
|
if (mode === 'dialog') {
|
|
391
|
-
|
|
506
|
+
// Collapsing a forced dialog is geometrically impossible, so Escape
|
|
507
|
+
// closes it (draft-preserving) instead of being swallowed by the
|
|
508
|
+
// re-expand.
|
|
509
|
+
if (prefersExpandedComposer || forcedDialog) handleClose();
|
|
510
|
+
else setMode('popover');
|
|
392
511
|
} else {
|
|
393
|
-
|
|
512
|
+
handleClose();
|
|
394
513
|
}
|
|
395
514
|
return;
|
|
396
515
|
}
|
|
@@ -411,11 +530,34 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
411
530
|
(allowEmptySubmit && initialText.trim().length > 0);
|
|
412
531
|
const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
|
|
413
532
|
|
|
533
|
+
// Shared by both footers. Disabled once anything is typed or attached so a
|
|
534
|
+
// click can never discard a draft; with content present, Save is the path.
|
|
535
|
+
const quickLookGoodButton = onQuickLookGood ? (
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={onQuickLookGood}
|
|
539
|
+
disabled={hasUnsavedContent}
|
|
540
|
+
className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-foreground hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
541
|
+
title={hasUnsavedContent ? 'Clear the comment to use Looks good' : 'Add "Looks good" without typing'}
|
|
542
|
+
>
|
|
543
|
+
<span aria-hidden="true">👍</span>
|
|
544
|
+
Looks good
|
|
545
|
+
</button>
|
|
546
|
+
) : null;
|
|
547
|
+
|
|
414
548
|
if (mode === 'dialog') {
|
|
415
549
|
return createPortal(
|
|
416
|
-
<div
|
|
550
|
+
<div
|
|
551
|
+
data-comment-popover="true"
|
|
552
|
+
className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
|
|
553
|
+
>
|
|
417
554
|
{/* Backdrop */}
|
|
418
|
-
<
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
aria-label="Dismiss comment"
|
|
558
|
+
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
|
559
|
+
onClick={() => handleClose()}
|
|
560
|
+
/>
|
|
419
561
|
|
|
420
562
|
{/* Dialog card */}
|
|
421
563
|
{/* The expanded dialog deliberately does not yield: it is an explicit
|
|
@@ -424,11 +566,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
424
566
|
meaningless there. */}
|
|
425
567
|
<div
|
|
426
568
|
ref={popoverRef}
|
|
427
|
-
|
|
569
|
+
role="dialog"
|
|
570
|
+
aria-modal="true"
|
|
571
|
+
aria-label={isGlobal ? 'Global comment' : 'Comment'}
|
|
572
|
+
tabIndex={-1}
|
|
573
|
+
className="relative w-full max-w-xl max-h-full min-h-0 bg-popover border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
|
428
574
|
style={{
|
|
429
575
|
animation: 'comment-dialog-in 0.15s ease-out',
|
|
430
576
|
}}
|
|
431
577
|
onPointerDown={(e) => e.stopPropagation()}
|
|
578
|
+
onKeyDown={(e) => {
|
|
579
|
+
if (e.key !== 'Escape') return;
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
e.stopPropagation();
|
|
582
|
+
handleClose();
|
|
583
|
+
}}
|
|
432
584
|
>
|
|
433
585
|
<style>{`
|
|
434
586
|
@keyframes comment-dialog-in {
|
|
@@ -443,15 +595,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
443
595
|
{headerLabel}
|
|
444
596
|
</span>
|
|
445
597
|
<div className="flex items-center gap-1">
|
|
598
|
+
{!prefersExpandedComposer && !forcedDialog && (
|
|
599
|
+
<button
|
|
600
|
+
onClick={() => { setDialogIsForced(false); setMode('popover'); }}
|
|
601
|
+
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
602
|
+
title="Collapse"
|
|
603
|
+
>
|
|
604
|
+
<CollapseIcon />
|
|
605
|
+
</button>
|
|
606
|
+
)}
|
|
446
607
|
<button
|
|
447
|
-
onClick={() =>
|
|
448
|
-
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
449
|
-
title="Collapse"
|
|
450
|
-
>
|
|
451
|
-
<CollapseIcon />
|
|
452
|
-
</button>
|
|
453
|
-
<button
|
|
454
|
-
onClick={onClose}
|
|
608
|
+
onClick={() => handleClose()}
|
|
455
609
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
456
610
|
title="Close"
|
|
457
611
|
>
|
|
@@ -463,9 +617,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
463
617
|
{chipsRow}
|
|
464
618
|
|
|
465
619
|
{/* Textarea */}
|
|
466
|
-
<div className="relative px-4 py-3 flex-1">
|
|
620
|
+
<div className="relative px-4 py-3 min-h-0 flex-1 overflow-y-auto">
|
|
467
621
|
{skillAc.menu && (
|
|
468
622
|
<SkillReferenceMenu
|
|
623
|
+
id={skillListboxId}
|
|
469
624
|
items={skillAc.menu.items}
|
|
470
625
|
activeIndex={skillAc.menu.activeIndex}
|
|
471
626
|
onSelect={skillAc.select}
|
|
@@ -478,16 +633,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
478
633
|
onKeyDown={handleKeyDown}
|
|
479
634
|
onSelectCaret={skillAc.onSelect}
|
|
480
635
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
481
|
-
sizeClassName="min-h-
|
|
636
|
+
sizeClassName="min-h-32 max-h-full"
|
|
482
637
|
skillReferences={skillReferences}
|
|
483
638
|
tokens={skillAc.referenceTokens}
|
|
639
|
+
listboxId={skillListboxId}
|
|
640
|
+
listboxOpen={skillAc.menu !== null}
|
|
641
|
+
activeOptionId={activeSkillOptionId}
|
|
484
642
|
/>
|
|
485
643
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
486
644
|
</div>
|
|
487
645
|
|
|
488
646
|
{/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
|
|
489
|
-
<div className="flex flex-row-reverse items-center justify-between px-4 py-3 border-t border-border/50">
|
|
490
|
-
<div className="flex flex-row-reverse items-center gap-3">
|
|
647
|
+
<div className="flex flex-row-reverse flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-border/50">
|
|
648
|
+
<div className="flex flex-row-reverse flex-wrap items-center gap-3">
|
|
491
649
|
<button
|
|
492
650
|
onClick={handleSubmit}
|
|
493
651
|
disabled={!canSubmit}
|
|
@@ -495,7 +653,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
495
653
|
>
|
|
496
654
|
{isGlobal ? 'Add' : 'Save'}
|
|
497
655
|
</button>
|
|
498
|
-
|
|
656
|
+
{!coarsePointer && (
|
|
657
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
658
|
+
)}
|
|
659
|
+
{quickLookGoodButton}
|
|
499
660
|
{onAskAI && (
|
|
500
661
|
<button
|
|
501
662
|
onClick={handleAskAI}
|
|
@@ -547,11 +708,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
547
708
|
data-comment-popover="true"
|
|
548
709
|
className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
|
|
549
710
|
style={dragPosition
|
|
550
|
-
? {
|
|
711
|
+
? {
|
|
712
|
+
top: dragPosition.top,
|
|
713
|
+
left: dragPosition.left,
|
|
714
|
+
width: position.width,
|
|
715
|
+
maxHeight: visibleBounds.height,
|
|
716
|
+
overflowY: 'auto',
|
|
717
|
+
}
|
|
551
718
|
: {
|
|
552
719
|
top: position.top,
|
|
553
720
|
left: position.left,
|
|
554
721
|
width: position.width,
|
|
722
|
+
maxHeight: position.maxHeight,
|
|
723
|
+
overflowY: 'auto',
|
|
555
724
|
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
|
|
556
725
|
animation: position.flipAbove
|
|
557
726
|
? 'comment-popover-in-above 0.15s ease-out'
|
|
@@ -579,14 +748,14 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
579
748
|
</span>
|
|
580
749
|
<div className="flex items-center gap-1">
|
|
581
750
|
<button
|
|
582
|
-
onClick={() => setMode('dialog')}
|
|
751
|
+
onClick={() => { setDialogIsForced(false); setMode('dialog'); }}
|
|
583
752
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
584
753
|
title="Expand"
|
|
585
754
|
>
|
|
586
755
|
<ExpandIcon />
|
|
587
756
|
</button>
|
|
588
757
|
<button
|
|
589
|
-
onClick={
|
|
758
|
+
onClick={() => handleClose()}
|
|
590
759
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
591
760
|
title="Close"
|
|
592
761
|
>
|
|
@@ -601,6 +770,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
601
770
|
<div className="relative px-3 py-2">
|
|
602
771
|
{skillAc.menu && (
|
|
603
772
|
<SkillReferenceMenu
|
|
773
|
+
id={skillListboxId}
|
|
604
774
|
items={skillAc.menu.items}
|
|
605
775
|
activeIndex={skillAc.menu.activeIndex}
|
|
606
776
|
onSelect={skillAc.select}
|
|
@@ -616,6 +786,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
616
786
|
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
617
787
|
skillReferences={skillReferences}
|
|
618
788
|
tokens={skillAc.referenceTokens}
|
|
789
|
+
listboxId={skillListboxId}
|
|
790
|
+
listboxOpen={skillAc.menu !== null}
|
|
791
|
+
activeOptionId={activeSkillOptionId}
|
|
619
792
|
/>
|
|
620
793
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
621
794
|
</div>
|
|
@@ -630,7 +803,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
630
803
|
>
|
|
631
804
|
{isGlobal ? 'Add' : 'Save'}
|
|
632
805
|
</button>
|
|
633
|
-
|
|
806
|
+
{!coarsePointer && (
|
|
807
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
808
|
+
)}
|
|
809
|
+
{quickLookGoodButton}
|
|
634
810
|
{onAskAI && (
|
|
635
811
|
<button
|
|
636
812
|
onClick={handleAskAI}
|
|
@@ -682,6 +858,10 @@ interface ComposerTextareaProps {
|
|
|
682
858
|
tokens: SkillReferenceToken[];
|
|
683
859
|
/** Off → render the plain pre-feature textarea, byte-for-byte. */
|
|
684
860
|
skillReferences: boolean;
|
|
861
|
+
/** ARIA relationship to the skill-reference listbox. */
|
|
862
|
+
listboxId: string;
|
|
863
|
+
listboxOpen: boolean;
|
|
864
|
+
activeOptionId?: string;
|
|
685
865
|
}
|
|
686
866
|
|
|
687
867
|
/**
|
|
@@ -706,6 +886,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
706
886
|
textareaRef,
|
|
707
887
|
tokens,
|
|
708
888
|
skillReferences,
|
|
889
|
+
listboxId,
|
|
890
|
+
listboxOpen,
|
|
891
|
+
activeOptionId,
|
|
709
892
|
}) => {
|
|
710
893
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
711
894
|
const [composing, setComposing] = useState(false);
|
|
@@ -735,6 +918,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
735
918
|
if (!skillReferences) {
|
|
736
919
|
return (
|
|
737
920
|
<textarea
|
|
921
|
+
data-pn-mobile-editable="true"
|
|
738
922
|
ref={attachRef}
|
|
739
923
|
value={value}
|
|
740
924
|
onChange={onChange}
|
|
@@ -780,6 +964,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
780
964
|
ref={overlayRef}
|
|
781
965
|
aria-hidden="true"
|
|
782
966
|
data-skill-ref-overlay="true"
|
|
967
|
+
data-pn-mobile-editable-mirror="true"
|
|
783
968
|
className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
|
|
784
969
|
style={composing ? { visibility: 'hidden' } : undefined}
|
|
785
970
|
>
|
|
@@ -787,6 +972,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
|
787
972
|
{'\n'}
|
|
788
973
|
</div>
|
|
789
974
|
<textarea
|
|
975
|
+
data-pn-mobile-editable="true"
|
|
976
|
+
aria-label={placeholder}
|
|
977
|
+
aria-autocomplete="list"
|
|
978
|
+
aria-haspopup="listbox"
|
|
979
|
+
aria-controls={listboxOpen ? listboxId : undefined}
|
|
980
|
+
aria-owns={listboxOpen ? listboxId : undefined}
|
|
981
|
+
aria-activedescendant={activeOptionId}
|
|
790
982
|
ref={attachRef}
|
|
791
983
|
value={value}
|
|
792
984
|
onChange={onChange}
|