@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.
Files changed (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. 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 on mount and mode changes
171
- useEffect(() => {
172
- const id = setTimeout(() => {
173
- const el = textareaRef.current;
174
- if (el) {
175
- el.focus();
176
- el.selectionStart = el.selectionEnd = el.value.length;
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
- return () => clearTimeout(id);
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
- <textarea
310
- ref={textareaRef}
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
- className="w-full bg-transparent text-sm placeholder:text-muted-foreground resize-none focus:outline-none min-h-48 max-h-96 px-1 py-0.5"
316
- style={{ fieldSizing: 'content' } as React.CSSProperties}
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-2">
323
- {allowImages && (
324
- <AttachmentsButton
325
- images={images}
326
- onAdd={(img) => setImages((prev) => [...prev, img])}
327
- onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
328
- variant="inline"
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
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
345
- <button
346
- onClick={handleSubmit}
347
- disabled={!canSubmit}
348
- 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"
349
- >
350
- {isGlobal ? 'Add' : 'Save'}
351
- </button>
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="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
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
- <textarea
432
- ref={textareaRef}
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
- className="w-full bg-transparent text-sm placeholder:text-muted-foreground resize-none focus:outline-none max-h-64 min-h-[4.5rem] px-1 py-0.5"
438
- style={{ fieldSizing: 'content' } as React.CSSProperties}
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-2">
445
- {allowImages && (
446
- <AttachmentsButton
447
- images={images}
448
- onAdd={(img) => setImages((prev) => [...prev, img])}
449
- onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))}
450
- variant="inline"
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
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
467
- <button
468
- onClick={handleSubmit}
469
- disabled={!canSubmit}
470
- 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"
471
- >
472
- {isGlobal ? 'Add' : 'Save'}
473
- </button>
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 = () => (
@@ -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 — the
9
- * sticky lane hides entirely in linked-doc mode, and the demo badge is purely
10
- * decorative top-of-doc context.
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 is hidden. Check what
68
- // will truly produce visible output to avoid an empty wrapper div.
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
- ? (!linkedDocInfo && ((hasPreviousVersion && planDiffStats) || archiveInfo))
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 && !linkedDocInfo && (
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