@plannotator/ui 0.28.0 → 0.29.1
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 +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
|
@@ -6,6 +6,9 @@ import { submitHint } from '../utils/platform';
|
|
|
6
6
|
import { useDraggable } from '../hooks/useDraggable';
|
|
7
7
|
import { SparklesIcon } from './SparklesIcon';
|
|
8
8
|
import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
9
|
+
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
10
|
+
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
11
|
+
import type { SkillReferenceToken } from '../utils/skillReferences';
|
|
9
12
|
|
|
10
13
|
export interface CommentAskAIContext {
|
|
11
14
|
kind: 'general' | 'selection';
|
|
@@ -19,6 +22,18 @@ export type CommentAskAIHandler = (
|
|
|
19
22
|
context: CommentAskAIContext,
|
|
20
23
|
) => boolean | void | Promise<boolean | void>;
|
|
21
24
|
|
|
25
|
+
/** One selected target of a multi-target draft comment (HTML pinpoint multi-select). */
|
|
26
|
+
export interface CommentTargetChip {
|
|
27
|
+
key: string;
|
|
28
|
+
/** Semantic label (hover-label cascade), e.g. "Button" / "rowchip". */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Short text excerpt of the target element. */
|
|
31
|
+
excerpt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Composer-yield stage while the user is shift-selecting (see composerYield.ts). */
|
|
35
|
+
export type CommentPopoverYieldState = 'none' | 'near' | 'over';
|
|
36
|
+
|
|
22
37
|
interface CommentPopoverProps {
|
|
23
38
|
/** Element to anchor the popover near (re-reads position on scroll) */
|
|
24
39
|
anchorEl?: HTMLElement;
|
|
@@ -46,6 +61,24 @@ interface CommentPopoverProps {
|
|
|
46
61
|
onAskAI?: CommentAskAIHandler;
|
|
47
62
|
askAIContext?: CommentAskAIContext;
|
|
48
63
|
askAIDisabled?: boolean;
|
|
64
|
+
/** Opt-in: `/` and `$` skill-reference autocomplete (document UI surfaces). Off by default. */
|
|
65
|
+
skillReferences?: boolean;
|
|
66
|
+
/** Opt-in (HTML multi-select): selected targets rendered as horizontally
|
|
67
|
+
* scrollable chips above the textarea. Absent → byte-identical composer. */
|
|
68
|
+
targetChips?: CommentTargetChip[];
|
|
69
|
+
/** Remove a chip's target while composing. */
|
|
70
|
+
onRemoveTargetChip?: (key: string) => void;
|
|
71
|
+
/** Chip hover — host flashes the corresponding element in the page. */
|
|
72
|
+
onHoverTargetChip?: (key: string) => void;
|
|
73
|
+
/** Opt-in: bump to return focus to the textarea (after a shift-click add/remove). */
|
|
74
|
+
refocusToken?: number;
|
|
75
|
+
/** Opt-in: while open, a window-level printable keydown that would otherwise
|
|
76
|
+
* go nowhere (focus on <body>) routes into the textarea, so the first
|
|
77
|
+
* keystroke after a shift-click is never lost. */
|
|
78
|
+
captureStrayKeys?: boolean;
|
|
79
|
+
/** Opt-in composer yield while shift-selecting: 'near' fades the composer,
|
|
80
|
+
* 'over' makes it near-invisible and click-through. Undefined → no-op. */
|
|
81
|
+
yieldState?: CommentPopoverYieldState;
|
|
49
82
|
}
|
|
50
83
|
|
|
51
84
|
const MAX_POPOVER_WIDTH = 384;
|
|
@@ -96,6 +129,13 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
96
129
|
onAskAI,
|
|
97
130
|
askAIContext,
|
|
98
131
|
askAIDisabled = false,
|
|
132
|
+
skillReferences = false,
|
|
133
|
+
targetChips,
|
|
134
|
+
onRemoveTargetChip,
|
|
135
|
+
onHoverTargetChip,
|
|
136
|
+
refocusToken,
|
|
137
|
+
captureStrayKeys = false,
|
|
138
|
+
yieldState,
|
|
99
139
|
}) => {
|
|
100
140
|
const [mode, setMode] = useState<'popover' | 'dialog'>('popover');
|
|
101
141
|
const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
|
|
@@ -167,17 +207,20 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
167
207
|
anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
168
208
|
}, [anchorEl]);
|
|
169
209
|
|
|
170
|
-
// Focus textarea
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
210
|
+
// Focus the textarea when it mounts (initial open and popover/dialog switches).
|
|
211
|
+
// A ref callback rather than a mount effect: in popover mode the textarea only
|
|
212
|
+
// renders after `position` is measured, and WebKit fires 0ms timers ahead of
|
|
213
|
+
// that commit, so an effect keyed on mode alone can run before the textarea
|
|
214
|
+
// exists and never focus it (e.g. in WKWebView hosts like Glimpse).
|
|
215
|
+
const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
216
|
+
textareaRef.current = el;
|
|
217
|
+
if (!el) return;
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
if (!el.isConnected) return;
|
|
220
|
+
el.focus();
|
|
221
|
+
el.selectionStart = el.selectionEnd = el.value.length;
|
|
178
222
|
}, 0);
|
|
179
|
-
|
|
180
|
-
}, [mode]);
|
|
223
|
+
}, []);
|
|
181
224
|
|
|
182
225
|
// Click-outside for popover mode
|
|
183
226
|
useEffect(() => {
|
|
@@ -198,6 +241,107 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
198
241
|
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
199
242
|
}, [mode, onClose]);
|
|
200
243
|
|
|
244
|
+
// Focus choreography (multi-select): after a shift-click adds/removes a
|
|
245
|
+
// target, focus returns to the textarea so typing continues uninterrupted.
|
|
246
|
+
// Focus only — the caret stays wherever the user left it mid-edit.
|
|
247
|
+
const refocusSeenRef = useRef(refocusToken);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (refocusToken === undefined || refocusToken === refocusSeenRef.current) return;
|
|
250
|
+
refocusSeenRef.current = refocusToken;
|
|
251
|
+
textareaRef.current?.focus();
|
|
252
|
+
}, [refocusToken]);
|
|
253
|
+
|
|
254
|
+
// First-keystroke guard: while the draft is open, a printable keydown that
|
|
255
|
+
// lands nowhere (focus fell back to <body> after an iframe interaction)
|
|
256
|
+
// routes into the textarea instead of vanishing. Runs in capture phase so
|
|
257
|
+
// it claims the key before the app's shortcut dispatcher; the character
|
|
258
|
+
// lands at the textarea's remembered caret. Note that preventDefault here
|
|
259
|
+
// does not stop the dispatcher (it deliberately ignores defaultPrevented,
|
|
260
|
+
// see shortcuts/runtime.ts) — this guard is only safe because the
|
|
261
|
+
// plan-review scopes bind no bare printable single key. Any future single-
|
|
262
|
+
// key binding on this surface must be reconciled with this handler.
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!captureStrayKeys) return;
|
|
265
|
+
const handler = (e: KeyboardEvent) => {
|
|
266
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
267
|
+
if (!e.key || e.key.length !== 1) return;
|
|
268
|
+
const target = e.target;
|
|
269
|
+
const strayed =
|
|
270
|
+
target === null
|
|
271
|
+
|| target === document.body
|
|
272
|
+
|| target === document.documentElement;
|
|
273
|
+
if (!strayed) return;
|
|
274
|
+
const el = textareaRef.current;
|
|
275
|
+
if (!el || document.activeElement === el) return;
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
const key = e.key;
|
|
278
|
+
const start = el.selectionStart ?? el.value.length;
|
|
279
|
+
const end = el.selectionEnd ?? start;
|
|
280
|
+
setText((prev) => prev.slice(0, start) + key + prev.slice(end));
|
|
281
|
+
el.focus();
|
|
282
|
+
requestAnimationFrame(() => {
|
|
283
|
+
el.selectionStart = el.selectionEnd = start + 1;
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
window.addEventListener('keydown', handler, true);
|
|
287
|
+
return () => window.removeEventListener('keydown', handler, true);
|
|
288
|
+
}, [captureStrayKeys]);
|
|
289
|
+
|
|
290
|
+
// Composer yield (multi-select): fade near the pointer, click-through over
|
|
291
|
+
// it. Class-driven so prefers-reduced-motion can kill the transition.
|
|
292
|
+
const yieldClass = yieldState === undefined
|
|
293
|
+
? ''
|
|
294
|
+
: ` pn-composer-yieldable${yieldState === 'over' ? ' pn-composer-yield-over' : yieldState === 'near' ? ' pn-composer-yield-near' : ''}`;
|
|
295
|
+
const yieldStyleBlock = yieldState === undefined ? null : (
|
|
296
|
+
<style>{`
|
|
297
|
+
.pn-composer-yieldable { transition: opacity 180ms ease; }
|
|
298
|
+
@media (prefers-reduced-motion: reduce) {
|
|
299
|
+
.pn-composer-yieldable { transition: none; }
|
|
300
|
+
}
|
|
301
|
+
.pn-composer-yield-near { opacity: 0.4; }
|
|
302
|
+
.pn-composer-yield-over { opacity: 0.05; pointer-events: none; }
|
|
303
|
+
`}</style>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Selected-target chips (multi-select): horizontally scrollable, primary
|
|
307
|
+
// first; each removable while composing.
|
|
308
|
+
const chipsRow = targetChips && targetChips.length > 0 ? (
|
|
309
|
+
<div
|
|
310
|
+
data-target-chips="true"
|
|
311
|
+
className="flex items-center gap-1.5 px-3 pt-2 overflow-x-auto whitespace-nowrap"
|
|
312
|
+
>
|
|
313
|
+
{targetChips.map((chip, i) => (
|
|
314
|
+
<span
|
|
315
|
+
key={chip.key}
|
|
316
|
+
data-target-chip={chip.key}
|
|
317
|
+
data-target-chip-primary={i === 0 ? 'true' : undefined}
|
|
318
|
+
onMouseEnter={() => onHoverTargetChip?.(chip.key)}
|
|
319
|
+
className={`inline-flex items-center gap-1 shrink-0 max-w-[180px] rounded-full border px-2 py-0.5 text-[10px] ${
|
|
320
|
+
i === 0
|
|
321
|
+
? 'border-primary/50 bg-primary/10 text-foreground'
|
|
322
|
+
: 'border-border bg-muted/50 text-muted-foreground'
|
|
323
|
+
}`}
|
|
324
|
+
>
|
|
325
|
+
<span className="font-semibold text-primary">{chip.label || 'Element'}</span>
|
|
326
|
+
<span className="truncate">{chip.excerpt}</span>
|
|
327
|
+
{onRemoveTargetChip && (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
data-target-chip-remove={chip.key}
|
|
331
|
+
onClick={() => onRemoveTargetChip(chip.key)}
|
|
332
|
+
title="Remove this target"
|
|
333
|
+
className="shrink-0 rounded-full p-0.5 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
334
|
+
>
|
|
335
|
+
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
336
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
337
|
+
</svg>
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
340
|
+
</span>
|
|
341
|
+
))}
|
|
342
|
+
</div>
|
|
343
|
+
) : null;
|
|
344
|
+
|
|
201
345
|
const handleSubmit = useCallback(() => {
|
|
202
346
|
const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0;
|
|
203
347
|
if (hasUnsavedContent || canSubmitEmpty) {
|
|
@@ -232,7 +376,15 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
232
376
|
onClose();
|
|
233
377
|
}, [allowImages, askAIContext, contextText, draftKey, isGlobal, onAskAI, onClose, onDraftChange, text]);
|
|
234
378
|
|
|
379
|
+
const skillAc = useSkillReferenceAutocomplete({
|
|
380
|
+
text,
|
|
381
|
+
setText,
|
|
382
|
+
textareaRef,
|
|
383
|
+
enabled: skillReferences,
|
|
384
|
+
});
|
|
385
|
+
|
|
235
386
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
387
|
+
if (skillAc.onKeyDown(e)) return;
|
|
236
388
|
if (e.key === 'Escape') {
|
|
237
389
|
e.stopPropagation();
|
|
238
390
|
if (mode === 'dialog') {
|
|
@@ -266,6 +418,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
266
418
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
|
|
267
419
|
|
|
268
420
|
{/* Dialog card */}
|
|
421
|
+
{/* The expanded dialog deliberately does not yield: it is an explicit
|
|
422
|
+
full-screen compose surface, and its backdrop wrapper (which carries
|
|
423
|
+
data-comment-popover) spans the viewport, so proximity is
|
|
424
|
+
meaningless there. */}
|
|
269
425
|
<div
|
|
270
426
|
ref={popoverRef}
|
|
271
427
|
className="relative w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
|
|
@@ -304,32 +460,42 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
304
460
|
</div>
|
|
305
461
|
</div>
|
|
306
462
|
|
|
463
|
+
{chipsRow}
|
|
464
|
+
|
|
307
465
|
{/* Textarea */}
|
|
308
|
-
<div className="px-4 py-3 flex-1">
|
|
309
|
-
|
|
310
|
-
|
|
466
|
+
<div className="relative px-4 py-3 flex-1">
|
|
467
|
+
{skillAc.menu && (
|
|
468
|
+
<SkillReferenceMenu
|
|
469
|
+
items={skillAc.menu.items}
|
|
470
|
+
activeIndex={skillAc.menu.activeIndex}
|
|
471
|
+
onSelect={skillAc.select}
|
|
472
|
+
/>
|
|
473
|
+
)}
|
|
474
|
+
<ComposerTextarea
|
|
475
|
+
textareaRef={focusOnMountRef}
|
|
311
476
|
value={text}
|
|
312
|
-
onChange={(e) => setText(e.target.value)}
|
|
477
|
+
onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
|
|
313
478
|
onKeyDown={handleKeyDown}
|
|
479
|
+
onSelectCaret={skillAc.onSelect}
|
|
314
480
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
315
|
-
|
|
316
|
-
|
|
481
|
+
sizeClassName="min-h-48 max-h-96"
|
|
482
|
+
skillReferences={skillReferences}
|
|
483
|
+
tokens={skillAc.referenceTokens}
|
|
317
484
|
/>
|
|
485
|
+
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
318
486
|
</div>
|
|
319
487
|
|
|
320
|
-
{/* Footer */}
|
|
321
|
-
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50">
|
|
322
|
-
<div className="flex items-center gap-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
</div>
|
|
332
|
-
<div className="flex items-center gap-3">
|
|
488
|
+
{/* 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">
|
|
491
|
+
<button
|
|
492
|
+
onClick={handleSubmit}
|
|
493
|
+
disabled={!canSubmit}
|
|
494
|
+
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
|
495
|
+
>
|
|
496
|
+
{isGlobal ? 'Add' : 'Save'}
|
|
497
|
+
</button>
|
|
498
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
333
499
|
{onAskAI && (
|
|
334
500
|
<button
|
|
335
501
|
onClick={handleAskAI}
|
|
@@ -341,14 +507,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
341
507
|
Ask AI
|
|
342
508
|
</button>
|
|
343
509
|
)}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
510
|
+
</div>
|
|
511
|
+
<div className="flex items-center gap-2">
|
|
512
|
+
{allowImages && (
|
|
513
|
+
<AttachmentsButton
|
|
514
|
+
images={images}
|
|
515
|
+
onAdd={(img) => setImages((prev) => [...prev, img])}
|
|
516
|
+
onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
|
|
517
|
+
variant="inline"
|
|
518
|
+
/>
|
|
519
|
+
)}
|
|
352
520
|
</div>
|
|
353
521
|
</div>
|
|
354
522
|
</div>
|
|
@@ -377,7 +545,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
377
545
|
<div
|
|
378
546
|
ref={popoverRef}
|
|
379
547
|
data-comment-popover="true"
|
|
380
|
-
className=
|
|
548
|
+
className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
|
|
381
549
|
style={dragPosition
|
|
382
550
|
? { top: dragPosition.top, left: dragPosition.left, width: position.width }
|
|
383
551
|
: {
|
|
@@ -402,6 +570,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
402
570
|
to { opacity: 1; transform: translateY(-100%); }
|
|
403
571
|
}
|
|
404
572
|
`}</style>
|
|
573
|
+
{yieldStyleBlock}
|
|
405
574
|
|
|
406
575
|
{/* Header (draggable) */}
|
|
407
576
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50" {...dragHandleProps}>
|
|
@@ -426,32 +595,42 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
426
595
|
</div>
|
|
427
596
|
</div>
|
|
428
597
|
|
|
598
|
+
{chipsRow}
|
|
599
|
+
|
|
429
600
|
{/* Textarea */}
|
|
430
|
-
<div className="px-3 py-2">
|
|
431
|
-
|
|
432
|
-
|
|
601
|
+
<div className="relative px-3 py-2">
|
|
602
|
+
{skillAc.menu && (
|
|
603
|
+
<SkillReferenceMenu
|
|
604
|
+
items={skillAc.menu.items}
|
|
605
|
+
activeIndex={skillAc.menu.activeIndex}
|
|
606
|
+
onSelect={skillAc.select}
|
|
607
|
+
/>
|
|
608
|
+
)}
|
|
609
|
+
<ComposerTextarea
|
|
610
|
+
textareaRef={focusOnMountRef}
|
|
433
611
|
value={text}
|
|
434
|
-
onChange={(e) => setText(e.target.value)}
|
|
612
|
+
onChange={(e) => { setText(e.target.value); skillAc.onSelect(); }}
|
|
435
613
|
onKeyDown={handleKeyDown}
|
|
614
|
+
onSelectCaret={skillAc.onSelect}
|
|
436
615
|
placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
|
|
437
|
-
|
|
438
|
-
|
|
616
|
+
sizeClassName="max-h-64 min-h-[4.5rem]"
|
|
617
|
+
skillReferences={skillReferences}
|
|
618
|
+
tokens={skillAc.referenceTokens}
|
|
439
619
|
/>
|
|
620
|
+
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
440
621
|
</div>
|
|
441
622
|
|
|
442
|
-
{/* Footer */}
|
|
443
|
-
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
|
444
|
-
<div className="flex items-center gap-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
</div>
|
|
454
|
-
<div className="flex items-center gap-3">
|
|
623
|
+
{/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
|
|
624
|
+
<div className="flex flex-row-reverse items-center justify-between px-3 py-2 border-t border-border/50">
|
|
625
|
+
<div className="flex flex-row-reverse items-center gap-3">
|
|
626
|
+
<button
|
|
627
|
+
onClick={handleSubmit}
|
|
628
|
+
disabled={!canSubmit}
|
|
629
|
+
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
|
630
|
+
>
|
|
631
|
+
{isGlobal ? 'Add' : 'Save'}
|
|
632
|
+
</button>
|
|
633
|
+
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
455
634
|
{onAskAI && (
|
|
456
635
|
<button
|
|
457
636
|
onClick={handleAskAI}
|
|
@@ -463,14 +642,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
463
642
|
Ask AI
|
|
464
643
|
</button>
|
|
465
644
|
)}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
645
|
+
</div>
|
|
646
|
+
<div className="flex items-center gap-2">
|
|
647
|
+
{allowImages && (
|
|
648
|
+
<AttachmentsButton
|
|
649
|
+
images={images}
|
|
650
|
+
onAdd={(img) => setImages((prev) => [...prev, img])}
|
|
651
|
+
onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
|
|
652
|
+
variant="inline"
|
|
653
|
+
/>
|
|
654
|
+
)}
|
|
474
655
|
</div>
|
|
475
656
|
</div>
|
|
476
657
|
</div>
|
|
@@ -479,6 +660,151 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
479
660
|
);
|
|
480
661
|
};
|
|
481
662
|
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Composer textarea with skill-reference token highlighting
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
/** Classes shared by the textarea and its highlight mirror — font, size,
|
|
668
|
+
* and box metrics MUST stay identical or the overlay drifts out of alignment. */
|
|
669
|
+
const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
|
|
670
|
+
|
|
671
|
+
interface ComposerTextareaProps {
|
|
672
|
+
value: string;
|
|
673
|
+
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
674
|
+
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
675
|
+
/** Caret observer (skill autocomplete). */
|
|
676
|
+
onSelectCaret: () => void;
|
|
677
|
+
placeholder: string;
|
|
678
|
+
/** Mode-specific min/max height classes. */
|
|
679
|
+
sizeClassName: string;
|
|
680
|
+
textareaRef: (el: HTMLTextAreaElement | null) => void;
|
|
681
|
+
/** Positioned skill-reference occurrences to highlight. */
|
|
682
|
+
tokens: SkillReferenceToken[];
|
|
683
|
+
/** Off → render the plain pre-feature textarea, byte-for-byte. */
|
|
684
|
+
skillReferences: boolean;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The composer's textarea. With `skillReferences` off this is exactly the
|
|
689
|
+
* pre-feature `<textarea>`; with it on, inserted skill-reference tokens are
|
|
690
|
+
* highlighted via a mirrored, aria-hidden overlay rendered BEHIND a
|
|
691
|
+
* transparent-text textarea (a textarea cannot style substrings). The overlay
|
|
692
|
+
* shares the exact font/padding/wrapping metrics and mirrors scroll position,
|
|
693
|
+
* and token spans change ONLY color/background (never font or weight), so the
|
|
694
|
+
* glyphs the browser lays out in the textarea and the glyphs the overlay
|
|
695
|
+
* paints coincide. During IME composition the overlay hides and the textarea
|
|
696
|
+
* text becomes visible again (`.pn-ref-composing`), keeping native
|
|
697
|
+
* composition rendering (underlines, candidate highlights) intact.
|
|
698
|
+
*/
|
|
699
|
+
const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
|
|
700
|
+
value,
|
|
701
|
+
onChange,
|
|
702
|
+
onKeyDown,
|
|
703
|
+
onSelectCaret,
|
|
704
|
+
placeholder,
|
|
705
|
+
sizeClassName,
|
|
706
|
+
textareaRef,
|
|
707
|
+
tokens,
|
|
708
|
+
skillReferences,
|
|
709
|
+
}) => {
|
|
710
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
711
|
+
const [composing, setComposing] = useState(false);
|
|
712
|
+
|
|
713
|
+
const syncScroll = useCallback((el: HTMLTextAreaElement) => {
|
|
714
|
+
const overlay = overlayRef.current;
|
|
715
|
+
if (!overlay) return;
|
|
716
|
+
overlay.scrollTop = el.scrollTop;
|
|
717
|
+
overlay.scrollLeft = el.scrollLeft;
|
|
718
|
+
}, []);
|
|
719
|
+
|
|
720
|
+
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
721
|
+
const attachRef = useCallback(
|
|
722
|
+
(el: HTMLTextAreaElement | null) => {
|
|
723
|
+
innerRef.current = el;
|
|
724
|
+
textareaRef(el);
|
|
725
|
+
},
|
|
726
|
+
[textareaRef],
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
// Keep the mirror aligned when the value changes without a scroll event
|
|
730
|
+
// (e.g. programmatic insertion moving the caret into a scrolled region).
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
if (innerRef.current) syncScroll(innerRef.current);
|
|
733
|
+
}, [value, syncScroll]);
|
|
734
|
+
|
|
735
|
+
if (!skillReferences) {
|
|
736
|
+
return (
|
|
737
|
+
<textarea
|
|
738
|
+
ref={attachRef}
|
|
739
|
+
value={value}
|
|
740
|
+
onChange={onChange}
|
|
741
|
+
onKeyDown={onKeyDown}
|
|
742
|
+
onSelect={onSelectCaret}
|
|
743
|
+
placeholder={placeholder}
|
|
744
|
+
className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none ${sizeClassName}`}
|
|
745
|
+
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
|
746
|
+
/>
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const segments: React.ReactNode[] = [];
|
|
751
|
+
let pos = 0;
|
|
752
|
+
tokens.forEach((token, i) => {
|
|
753
|
+
if (token.start < pos || token.end > value.length) return; // stale tokens for a different value
|
|
754
|
+
if (token.start > pos) segments.push(value.slice(pos, token.start));
|
|
755
|
+
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
756
|
+
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
757
|
+
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
758
|
+
// the textarea — this overlay is aria-hidden.
|
|
759
|
+
segments.push(
|
|
760
|
+
<span
|
|
761
|
+
key={`${token.start}-${i}`}
|
|
762
|
+
data-skill-ref-token={token.entry.name}
|
|
763
|
+
data-skill-ref-human-only={token.entry.humanOnly ? 'true' : undefined}
|
|
764
|
+
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
765
|
+
token.entry.humanOnly
|
|
766
|
+
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
767
|
+
: ''
|
|
768
|
+
}`}
|
|
769
|
+
>
|
|
770
|
+
{value.slice(token.start, token.end)}
|
|
771
|
+
</span>,
|
|
772
|
+
);
|
|
773
|
+
pos = token.end;
|
|
774
|
+
});
|
|
775
|
+
segments.push(value.slice(pos));
|
|
776
|
+
|
|
777
|
+
return (
|
|
778
|
+
<div className="relative">
|
|
779
|
+
<div
|
|
780
|
+
ref={overlayRef}
|
|
781
|
+
aria-hidden="true"
|
|
782
|
+
data-skill-ref-overlay="true"
|
|
783
|
+
className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
|
|
784
|
+
style={composing ? { visibility: 'hidden' } : undefined}
|
|
785
|
+
>
|
|
786
|
+
{segments}
|
|
787
|
+
{'\n'}
|
|
788
|
+
</div>
|
|
789
|
+
<textarea
|
|
790
|
+
ref={attachRef}
|
|
791
|
+
value={value}
|
|
792
|
+
onChange={onChange}
|
|
793
|
+
onKeyDown={onKeyDown}
|
|
794
|
+
onSelect={onSelectCaret}
|
|
795
|
+
onScroll={(e) => syncScroll(e.currentTarget)}
|
|
796
|
+
onCompositionStart={() => setComposing(true)}
|
|
797
|
+
onCompositionEnd={() => setComposing(false)}
|
|
798
|
+
placeholder={placeholder}
|
|
799
|
+
className={`${COMPOSER_TEXT_CLASSES} placeholder:text-muted-foreground resize-none focus:outline-none relative pn-ref-input ${
|
|
800
|
+
composing ? 'pn-ref-composing' : ''
|
|
801
|
+
} ${sizeClassName}`}
|
|
802
|
+
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
|
803
|
+
/>
|
|
804
|
+
</div>
|
|
805
|
+
);
|
|
806
|
+
};
|
|
807
|
+
|
|
482
808
|
// Icons
|
|
483
809
|
|
|
484
810
|
const ChevronUpIcon = () => (
|
package/components/DocBadges.tsx
CHANGED
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
* - layout="column": original location at the top-left of the plan card (absolute)
|
|
6
6
|
* - layout="row": inside the sticky header lane when the user scrolls
|
|
7
7
|
*
|
|
8
|
-
* In row layout, the demo badge and linked-doc breadcrumb are dropped —
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* In row layout, the demo badge and linked-doc breadcrumb are dropped —
|
|
9
|
+
* everything else is decorative top-of-doc context except the plan-diff
|
|
10
|
+
* badge, which is NOT suppressed by linkedDocInfo: `planDiffStats`/
|
|
11
|
+
* `hasPreviousVersion` already arrive pre-scoped to whichever document is
|
|
12
|
+
* currently active (root, or a folder/linked doc with its own diff
|
|
13
|
+
* baseline), so it renders in linked-doc mode exactly when that document
|
|
14
|
+
* actually has a previous version to diff against.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import React from 'react';
|
|
@@ -31,6 +35,11 @@ export interface DocBadgesProps {
|
|
|
31
35
|
isPlanDiffActive?: boolean;
|
|
32
36
|
hasPreviousVersion?: boolean;
|
|
33
37
|
onPlanDiffToggle?: () => void;
|
|
38
|
+
/** Baseline suffix + tooltip for the plan-diff badge — annotate/folder
|
|
39
|
+
* sessions pass "since last review" so the badge's numbers read against
|
|
40
|
+
* the right baseline. See PlanDiffBadge. Plan review passes nothing. */
|
|
41
|
+
planDiffBaselineLabel?: string;
|
|
42
|
+
planDiffBaselineTooltip?: string;
|
|
34
43
|
showDemoBadge?: boolean;
|
|
35
44
|
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
|
|
36
45
|
linkedDocInfo?: LinkedDocBadgeInfo | null;
|
|
@@ -50,6 +59,8 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
50
59
|
isPlanDiffActive,
|
|
51
60
|
hasPreviousVersion,
|
|
52
61
|
onPlanDiffToggle,
|
|
62
|
+
planDiffBaselineLabel,
|
|
63
|
+
planDiffBaselineTooltip,
|
|
53
64
|
showDemoBadge,
|
|
54
65
|
archiveInfo,
|
|
55
66
|
linkedDocInfo,
|
|
@@ -64,10 +75,12 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
64
75
|
) : null;
|
|
65
76
|
|
|
66
77
|
// In row layout, only PlanDiffBadge (when it has stats to show) and
|
|
67
|
-
// archiveInfo actually render — everything else
|
|
68
|
-
// will truly produce visible output to avoid an
|
|
78
|
+
// archiveInfo (outside linked-doc mode) actually render — everything else
|
|
79
|
+
// is hidden. Check what will truly produce visible output to avoid an
|
|
80
|
+
// empty wrapper div. PlanDiffBadge is not linkedDocInfo-gated — see the
|
|
81
|
+
// header comment.
|
|
69
82
|
const anything = isRow
|
|
70
|
-
? (
|
|
83
|
+
? (hasPreviousVersion && planDiffStats) || (archiveInfo && !linkedDocInfo)
|
|
71
84
|
: repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || canOpenInApp;
|
|
72
85
|
if (!anything) return null;
|
|
73
86
|
|
|
@@ -78,10 +91,6 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
78
91
|
|
|
79
92
|
return (
|
|
80
93
|
<div className={outerClass}>
|
|
81
|
-
{/* Open-in-app normally renders inline (to the right) within the source /
|
|
82
|
-
linked-doc rows below. This standalone fallback only fires when there
|
|
83
|
-
is no file row to attach to. Hidden in the sticky row and for URLs. */}
|
|
84
|
-
{!isRow && canOpenInApp && !sourceInfo && !linkedDocInfo && openInButton}
|
|
85
94
|
{/* Row layout (sticky lane) omits repo/branch to keep the bar compact —
|
|
86
95
|
they'd otherwise push the container wide enough to visually extend
|
|
87
96
|
under the action buttons. Plan-diff badge still renders below. */}
|
|
@@ -104,9 +113,16 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
104
113
|
<span className="truncate">{repoInfo.branch}</span>
|
|
105
114
|
</span>
|
|
106
115
|
)}
|
|
116
|
+
{/* Markdown/text annotate sessions have no separate source badge, so
|
|
117
|
+
keep the open-in control attached to the repository context. */}
|
|
118
|
+
{canOpenInApp && !sourceInfo && openInButton}
|
|
107
119
|
</div>
|
|
108
120
|
)}
|
|
109
121
|
|
|
122
|
+
{/* Non-repository files still need an open-in entry when there is no
|
|
123
|
+
source or linked-document row to attach it to. */}
|
|
124
|
+
{!isRow && canOpenInApp && !repoInfo && !sourceInfo && !linkedDocInfo && openInButton}
|
|
125
|
+
|
|
110
126
|
{sourceInfo && !linkedDocInfo && !isRow && (
|
|
111
127
|
<div className="flex items-center gap-1">
|
|
112
128
|
<span
|
|
@@ -121,12 +137,14 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
121
137
|
</div>
|
|
122
138
|
)}
|
|
123
139
|
|
|
124
|
-
{onPlanDiffToggle &&
|
|
140
|
+
{onPlanDiffToggle && (
|
|
125
141
|
<PlanDiffBadge
|
|
126
142
|
stats={planDiffStats ?? null}
|
|
127
143
|
isActive={isPlanDiffActive ?? false}
|
|
128
144
|
onToggle={onPlanDiffToggle}
|
|
129
145
|
hasPreviousVersion={hasPreviousVersion ?? false}
|
|
146
|
+
baselineLabel={planDiffBaselineLabel}
|
|
147
|
+
baselineTooltip={planDiffBaselineTooltip}
|
|
130
148
|
/>
|
|
131
149
|
)}
|
|
132
150
|
|