@plannotator/ui 0.28.0 → 0.29.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.
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
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { Origin } from '@plannotator/core/agents';
4
4
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
@@ -28,9 +28,11 @@ import {
28
28
  } from '../utils/octarine';
29
29
  import {
30
30
  getAgentSwitchSettings,
31
+ getAgentSwitchDefaults,
31
32
  saveAgentSwitchSettings,
32
33
  AGENT_OPTIONS,
33
34
  type AgentSwitchSettings,
35
+ type AgentSwitchSurface,
34
36
  } from '../utils/agentSwitch';
35
37
  import {
36
38
  getPlanSaveSettings,
@@ -70,8 +72,9 @@ import {
70
72
  saveFileBrowserSettings,
71
73
  type FileBrowserSettings,
72
74
  } from '../utils/fileBrowser';
75
+ import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
73
76
 
74
- type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
77
+ type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
75
78
 
76
79
  interface SettingsProps {
77
80
  taterMode: boolean;
@@ -169,23 +172,27 @@ function SegmentedControl<T extends string>({ options, value, onChange }: {
169
172
  );
170
173
  }
171
174
 
172
- function ToggleSwitch({ checked, onChange, label, description }: {
175
+ function ToggleSwitch({ checked, onChange, label, description, disabled = false }: {
173
176
  checked: boolean;
174
177
  onChange: (v: boolean) => void;
175
178
  label: string;
176
179
  description?: string;
180
+ disabled?: boolean;
177
181
  }) {
178
182
  return (
179
- <div className="flex items-center justify-between">
183
+ <div className={`flex items-center justify-between gap-4 ${disabled ? 'opacity-50' : ''}`}>
180
184
  <div>
181
185
  <div className="text-sm font-medium">{label}</div>
182
186
  {description && <div className="text-xs text-muted-foreground">{description}</div>}
183
187
  </div>
184
188
  <button
189
+ type="button"
185
190
  role="switch"
191
+ aria-label={label}
186
192
  aria-checked={checked}
193
+ disabled={disabled}
187
194
  onClick={() => onChange(!checked)}
188
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
195
+ className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${
189
196
  checked ? 'bg-primary' : 'bg-muted'
190
197
  }`}
191
198
  >
@@ -199,6 +206,88 @@ function ToggleSwitch({ checked, onChange, label, description }: {
199
206
  );
200
207
  }
201
208
 
209
+ interface VimSettingsTabProps {
210
+ readonly vimModeEnabled: boolean;
211
+ readonly vimHudEnabled: boolean;
212
+ readonly vimHudKeyPanelEnabled: boolean;
213
+ readonly onVimModeChange: (enabled: boolean) => void;
214
+ readonly onVimHudChange: (enabled: boolean) => void;
215
+ readonly onVimHudKeyPanelChange: (enabled: boolean) => void;
216
+ }
217
+
218
+ /** First-class Vim configuration, separate from the shortcut reference. */
219
+ function VimSettingsTab({
220
+ vimModeEnabled,
221
+ vimHudEnabled,
222
+ vimHudKeyPanelEnabled,
223
+ onVimModeChange,
224
+ onVimHudChange,
225
+ onVimHudKeyPanelChange,
226
+ }: VimSettingsTabProps) {
227
+ return (
228
+ <div className="space-y-5">
229
+ <div>
230
+ <div className="text-sm font-semibold">Vim mode</div>
231
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
232
+ Navigate, select, and annotate the rendered document without reaching for the mouse.
233
+ </p>
234
+ </div>
235
+
236
+ <div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
237
+ <ToggleSwitch
238
+ checked={vimModeEnabled}
239
+ onChange={onVimModeChange}
240
+ label="Vim controls"
241
+ description="Use Vim-style keys for document navigation and annotation. Off by default."
242
+ />
243
+ <div className="border-t border-border" />
244
+ <ToggleSwitch
245
+ checked={vimModeEnabled && vimHudEnabled}
246
+ onChange={onVimHudChange}
247
+ label="Vim HUD"
248
+ description={
249
+ vimModeEnabled
250
+ ? 'Show the four-corner target reticle and navigation context.'
251
+ : 'Enable Vim controls first to use the target reticle.'
252
+ }
253
+ disabled={!vimModeEnabled}
254
+ />
255
+ <div className="border-t border-border" />
256
+ <ToggleSwitch
257
+ checked={vimModeEnabled && vimHudEnabled && vimHudKeyPanelEnabled}
258
+ onChange={onVimHudKeyPanelChange}
259
+ label="Key panel"
260
+ description={
261
+ vimModeEnabled && vimHudEnabled
262
+ ? 'Show the bottom-right live keypress legend and command panel.'
263
+ : 'Enable the Vim HUD first to use the live keypress legend.'
264
+ }
265
+ disabled={!vimModeEnabled || !vimHudEnabled}
266
+ />
267
+ </div>
268
+
269
+ <div className="rounded-xl border border-primary/20 bg-primary/[0.05] p-4">
270
+ <div className="flex items-start gap-3">
271
+ <span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-primary/15 font-mono text-xs font-bold text-primary">
272
+ ?
273
+ </span>
274
+ <div>
275
+ <div className="text-xs font-semibold">Learn while you navigate</div>
276
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
277
+ Vim takes focus automatically when the page is ready. From other app controls, press{' '}
278
+ <span className="font-mono text-foreground">Esc</span> to return to the document, then
279
+ use <span className="font-mono text-foreground">j</span> and{' '}
280
+ <span className="font-mono text-foreground">k</span> to move by block. Press{' '}
281
+ <span className="font-mono text-foreground">?</span> for the complete key map,
282
+ even when the Key panel is hidden.
283
+ </p>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ );
289
+ }
290
+
202
291
  const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
203
292
  const defaultDiffType = useConfigValue('defaultDiffType');
204
293
  const reviewPanelView = useConfigValue('reviewPanelView');
@@ -278,6 +367,7 @@ const ReviewDisplayTab: React.FC = () => {
278
367
  const diffShowBackground = useConfigValue('diffShowBackground');
279
368
  const diffLineBgIntensity = useConfigValue('diffLineBgIntensity');
280
369
  const diffHideWhitespace = useConfigValue('diffHideWhitespace');
370
+ const editSuggestions = useConfigValue('editSuggestions');
281
371
  const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
282
372
  const diffFontFamily = useConfigValue('diffFontFamily');
283
373
  const diffFontSize = useConfigValue('diffFontSize');
@@ -289,6 +379,21 @@ const ReviewDisplayTab: React.FC = () => {
289
379
 
290
380
  return (
291
381
  <>
382
+ {/* Experimental: edit code to author suggestions */}
383
+ <div className="space-y-3">
384
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
385
+ Experimental
386
+ </div>
387
+ <ToggleSwitch
388
+ checked={editSuggestions}
389
+ onChange={(v) => configStore.set('editSuggestions', v)}
390
+ label="Edit Code to Suggest"
391
+ description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor."
392
+ />
393
+ </div>
394
+
395
+ <div className="border-t border-border" />
396
+
292
397
  {/* Font Family */}
293
398
  <div className="space-y-2">
294
399
  <div>
@@ -443,6 +548,9 @@ const ReviewDisplayTab: React.FC = () => {
443
548
  description="Ignore whitespace-only changes in diffs"
444
549
  />
445
550
 
551
+ <div className="border-t border-border" />
552
+
553
+
446
554
  </>
447
555
  );
448
556
  };
@@ -653,8 +761,23 @@ const CommentsTab: React.FC = () => {
653
761
 
654
762
  export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => {
655
763
  const [showDialog, setShowDialog] = useState(false);
764
+ const settingsWasOpenRef = useRef(false);
656
765
  const [themePreview, setThemePreview] = useState(false);
657
766
 
767
+ useEffect(() => {
768
+ const wasOpen = settingsWasOpenRef.current;
769
+ settingsWasOpenRef.current = showDialog;
770
+ if (
771
+ wasOpen
772
+ && !showDialog
773
+ && !themePreview
774
+ && mode !== 'review'
775
+ && configStore.get('vimModeEnabled')
776
+ ) {
777
+ requestVimDocumentFocus();
778
+ }
779
+ }, [mode, showDialog, themePreview]);
780
+
658
781
  useEffect(() => {
659
782
  if (!themePreview) return;
660
783
  const handler = (e: KeyboardEvent) => {
@@ -665,6 +788,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
665
788
  }, [themePreview]);
666
789
  const [activeTab, setActiveTab] = useState<SettingsTab>('general');
667
790
  const gridEnabled = useConfigValue('gridEnabled');
791
+ const vimModeEnabled = useConfigValue('vimModeEnabled');
792
+ const vimHudEnabled = useConfigValue('vimHudEnabled');
793
+ const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
668
794
  const [identity, setIdentity] = useState('');
669
795
  const [obsidian, setObsidian] = useState<ObsidianSettings>({
670
796
  enabled: false,
@@ -678,7 +804,10 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
678
804
  const [vaultsLoading, setVaultsLoading] = useState(false);
679
805
  const [bear, setBear] = useState<BearSettings>({ enabled: false, customTags: '', tagPosition: 'append', autoSave: false });
680
806
  const [octarine, setOctarine] = useState<OctarineSettings>({ enabled: false, workspace: '', folder: 'plannotator', autoSave: false });
681
- const [agent, setAgent] = useState<AgentSwitchSettings>({ switchTo: 'build' });
807
+ // The agent switch default depends on the surface: plan approval hands off to
808
+ // the build agent, review feedback stays on the current agent.
809
+ const agentSurface: AgentSwitchSurface = mode === 'review' ? 'review' : 'plan';
810
+ const [agent, setAgent] = useState<AgentSwitchSettings>(() => getAgentSwitchDefaults(agentSurface));
682
811
  const [planSave, setPlanSave] = useState<PlanSaveSettings>({ enabled: true, customPath: null });
683
812
  const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true, planWidth: 'compact' });
684
813
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
@@ -693,7 +822,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
693
822
  const [newDirPath, setNewDirPath] = useState('');
694
823
 
695
824
  // Fetch available agents for OpenCode
696
- const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null);
825
+ const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null, agentSurface);
697
826
 
698
827
  const mainTabs = useMemo(() => {
699
828
  const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }];
@@ -705,12 +834,15 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
705
834
  }
706
835
  if (mode === 'review') {
707
836
  t.push({ id: 'git', label: 'Git' });
708
- t.push({ id: 'display', label: 'Display' });
837
+ t.push({ id: 'display', label: 'Editor' });
709
838
  t.push({ id: 'comments', label: 'Comments' });
710
839
  if (aiProviders.length > 0) {
711
840
  t.push({ id: 'ai', label: 'AI' });
712
841
  }
713
842
  }
843
+ if (mode !== 'review') {
844
+ t.push({ id: 'vim', label: 'Vim' });
845
+ }
714
846
  t.push({ id: 'shortcuts', label: 'Shortcuts' });
715
847
  if (mode === 'plan') {
716
848
  t.push({ id: 'hooks', label: 'Hooks' });
@@ -742,7 +874,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
742
874
  setObsidian(getObsidianSettings());
743
875
  setBear(getBearSettings());
744
876
  setOctarine(getOctarineSettings());
745
- setAgent(getAgentSwitchSettings());
877
+ setAgent(getAgentSwitchSettings(agentSurface));
746
878
  setPlanSave(getPlanSaveSettings());
747
879
  setUiPrefs(getUIPreferences());
748
880
  setPermissionMode(getPermissionModeSettings().mode);
@@ -914,12 +1046,23 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
914
1046
  >
915
1047
  <div
916
1048
  className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl relative overflow-hidden"
1049
+ role="dialog"
1050
+ aria-modal="true"
1051
+ aria-labelledby="plannotator-settings-title"
917
1052
  onClick={e => e.stopPropagation()}
1053
+ onKeyDown={(event) => {
1054
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
1055
+ event.preventDefault();
1056
+ event.stopPropagation();
1057
+ setShowDialog(false);
1058
+ }}
918
1059
  >
919
1060
  {taterMode && <TaterSpritePullup />}
920
1061
  <div className="flex items-center justify-between p-4 border-b border-border">
921
- <h3 className="font-semibold text-sm">Settings</h3>
1062
+ <h3 id="plannotator-settings-title" className="font-semibold text-sm">Settings</h3>
922
1063
  <button
1064
+ type="button"
1065
+ aria-label="Close settings"
923
1066
  onClick={() => setShowDialog(false)}
924
1067
  className="p-1.5 rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors"
925
1068
  >
@@ -1655,7 +1798,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1655
1798
  saveQuickLabels(updated);
1656
1799
  setEditingTipIndex(null);
1657
1800
  }
1658
- if (e.key === 'Escape') setEditingTipIndex(null);
1801
+ if (e.key === 'Escape') {
1802
+ e.preventDefault();
1803
+ e.stopPropagation();
1804
+ setEditingTipIndex(null);
1805
+ }
1659
1806
  }}
1660
1807
  placeholder="AI instruction tip..."
1661
1808
  className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
@@ -1709,9 +1856,22 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1709
1856
  </>
1710
1857
  )}
1711
1858
 
1859
+ {/* === VIM TAB === */}
1860
+ {activeTab === 'vim' && mode !== 'review' && (
1861
+ <VimSettingsTab
1862
+ vimModeEnabled={vimModeEnabled}
1863
+ vimHudEnabled={vimHudEnabled}
1864
+ vimHudKeyPanelEnabled={vimHudKeyPanelEnabled}
1865
+ onVimModeChange={(enabled) => configStore.set('vimModeEnabled', enabled)}
1866
+ onVimHudChange={(enabled) => configStore.set('vimHudEnabled', enabled)}
1867
+ onVimHudKeyPanelChange={(enabled) =>
1868
+ configStore.set('vimHudKeyPanelEnabled', enabled)}
1869
+ />
1870
+ )}
1871
+
1712
1872
  {/* === SHORTCUTS TAB === */}
1713
1873
  {activeTab === 'shortcuts' && (
1714
- <KeyboardShortcuts mode={mode} />
1874
+ <KeyboardShortcuts mode={mode} vimModeEnabled={vimModeEnabled} />
1715
1875
  )}
1716
1876
 
1717
1877
  {/* === COMMENTS TAB === */}
@@ -0,0 +1,260 @@
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { SkillCatalogEntry } from '../utils/skillReferences';
3
+
4
+ /** Which skill root a row came from, shown as the right-aligned source column. */
5
+ const ROOT_LABELS: Record<SkillCatalogEntry['root'], string> = {
6
+ claude: 'Claude',
7
+ codex: 'Codex',
8
+ universal: 'Agents',
9
+ };
10
+
11
+ interface SkillReferenceMenuProps {
12
+ items: SkillCatalogEntry[];
13
+ /** Explicitly activated row, or null — the menu opens with nothing active. */
14
+ activeIndex: number | null;
15
+ onSelect: (index: number) => void;
16
+ }
17
+
18
+ /** Vertical gap between the composer wrapper and the menu (mb-1.5 / mt-1.5 = 6px). */
19
+ const MENU_GAP = 6;
20
+ /** Breathing room kept between the menu and the viewport edge. */
21
+ const MENU_VIEWPORT_MARGIN = 8;
22
+ /** Upper bound on the scrollable list height — the former fixed `max-h-64`. */
23
+ const MAX_LIST_HEIGHT = 256;
24
+
25
+ interface MenuPlacement {
26
+ direction: 'above' | 'below';
27
+ maxListHeight: number;
28
+ }
29
+
30
+ /**
31
+ * Adaptive placement, the same viewport-measurement idiom CommentPopover's
32
+ * `computePosition` uses (space vs `window.innerHeight`). The menu PREFERS
33
+ * opening above the composer — that keeps the text being typed, the action
34
+ * row, and the human-only notice unobstructed, and matches how chat-composer
35
+ * autocompletes behave — and flips below when the list does not fit above but
36
+ * does fit below (the popover near the top of the viewport: the exact case
37
+ * where the old fixed `bottom-full` menu ran off the top of the screen).
38
+ * When neither side fits the whole list, the side with more room wins and the
39
+ * list is clamped to it, so the menu never extends past a viewport edge.
40
+ */
41
+ function computeMenuPlacement(
42
+ anchorRect: Pick<DOMRect, 'top' | 'bottom'>,
43
+ naturalListHeight: number,
44
+ chromeHeight: number,
45
+ viewportHeight: number,
46
+ ): MenuPlacement {
47
+ const spaceAbove = anchorRect.top - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
48
+ const spaceBelow = viewportHeight - anchorRect.bottom - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
49
+ const needed = Math.min(naturalListHeight, MAX_LIST_HEIGHT);
50
+ const direction: MenuPlacement['direction'] =
51
+ needed <= spaceAbove ? 'above'
52
+ : needed <= spaceBelow ? 'below'
53
+ : spaceAbove >= spaceBelow ? 'above'
54
+ : 'below';
55
+ const available = direction === 'above' ? spaceAbove : spaceBelow;
56
+ return {
57
+ direction,
58
+ maxListHeight: Math.max(0, Math.min(MAX_LIST_HEIGHT, Math.round(available))),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Dropdown for skill references inside a comment composer. Rendered adjacent
64
+ * to the textarea (absolute within a relative wrapper), opening above or
65
+ * below depending on available viewport space (see computeMenuPlacement), and
66
+ * clamped so it never runs off screen. Each row: icon, bold name, dimmed
67
+ * inline description (ellipsis-truncated), right-aligned source root.
68
+ * Human-invocation-only skills render identically to every other row: their
69
+ * instructions are injected into the exported feedback automatically, so the
70
+ * distinction needs no surfacing at pick time. (A hover-disclosed warning
71
+ * used to live here; because it changed the menu's height while the pointer
72
+ * sat over a row of a bottom-anchored menu, it oscillated the row under the
73
+ * cursor every frame. Hover must never change any row's rendered size or the
74
+ * menu's measured geometry — color-only hover styling is fine.)
75
+ *
76
+ * Activation is KEYBOARD-ONLY (see useSkillReferenceAutocomplete): the menu
77
+ * floats directly over the composer, exactly where the mouse rests while
78
+ * typing, so pointer hover must never arm the "Enter inserts" state. Hover is
79
+ * a purely visual affordance; a pointer click inserts directly.
80
+ */
81
+ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
82
+ items,
83
+ activeIndex,
84
+ onSelect,
85
+ }) => {
86
+ const menuRef = useRef<HTMLDivElement>(null);
87
+ const listRef = useRef<HTMLDivElement>(null);
88
+ const [placement, setPlacement] = useState<MenuPlacement>({
89
+ direction: 'above',
90
+ maxListHeight: MAX_LIST_HEIGHT,
91
+ });
92
+
93
+ const measure = useCallback(() => {
94
+ const menuEl = menuRef.current;
95
+ const listEl = listRef.current;
96
+ // The menu is a direct child of the composer's `relative` wrapper — the
97
+ // same box the `bottom-full` / `top-full` CSS anchors to.
98
+ const anchor = menuEl?.parentElement;
99
+ if (!menuEl || !listEl || !anchor) return;
100
+ const rect = anchor.getBoundingClientRect();
101
+ // Non-list height the menu carries (border). This must stay constant
102
+ // while the pointer moves — hover-dependent chrome is what caused the
103
+ // jitter loop this component once shipped.
104
+ const chrome = Math.max(0, menuEl.offsetHeight - listEl.offsetHeight);
105
+ // scrollHeight reports full content height even while clamped.
106
+ const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, window.innerHeight);
107
+ setPlacement((prev) =>
108
+ prev.direction === next.direction && prev.maxListHeight === next.maxListHeight
109
+ ? prev
110
+ : next,
111
+ );
112
+ }, []);
113
+
114
+ // Re-measure on EVERY commit: the popover re-renders on drag moves, flips,
115
+ // and filtering (item-count changes), and each of those can change the
116
+ // geometry without any window event firing. The setState above is
117
+ // equality-guarded, so this converges instead of looping.
118
+ useLayoutEffect(() => {
119
+ measure();
120
+ });
121
+
122
+ // Same listeners CommentPopover's own position tracking uses: capture-phase
123
+ // scroll (the document scrolls under the anchored popover) plus resize.
124
+ useEffect(() => {
125
+ window.addEventListener('scroll', measure, true);
126
+ window.addEventListener('resize', measure);
127
+ return () => {
128
+ window.removeEventListener('scroll', measure, true);
129
+ window.removeEventListener('resize', measure);
130
+ };
131
+ }, [measure]);
132
+
133
+ useEffect(() => {
134
+ if (activeIndex === null) return;
135
+ const list = listRef.current;
136
+ const row = list?.children[activeIndex] as HTMLElement | undefined;
137
+ row?.scrollIntoView({ block: 'nearest' });
138
+ }, [activeIndex]);
139
+
140
+ return (
141
+ <div
142
+ ref={menuRef}
143
+ data-skill-menu="true"
144
+ data-skill-menu-placement={placement.direction}
145
+ data-popover-layer="true"
146
+ className={`absolute left-0 right-0 z-[110] bg-popover border border-border rounded-xl shadow-2xl overflow-hidden ${
147
+ placement.direction === 'above' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
148
+ }`}
149
+ >
150
+ <div
151
+ ref={listRef}
152
+ data-skill-menu-list="true"
153
+ className="overflow-y-auto p-1.5 flex flex-col gap-px"
154
+ style={{ maxHeight: placement.maxListHeight }}
155
+ >
156
+ {items.map((item, index) => (
157
+ <button
158
+ key={item.name}
159
+ type="button"
160
+ data-skill-item={item.name}
161
+ data-skill-item-active={index === activeIndex ? 'true' : undefined}
162
+ // Insert on pointerdown so the textarea never loses focus. A
163
+ // click is explicit selection; hover deliberately does NOT
164
+ // activate the row (see the component docblock), and its only
165
+ // styling is the color-only hover:bg — hover must never change
166
+ // a row's rendered size or the menu's geometry.
167
+ onPointerDown={(e) => {
168
+ e.preventDefault();
169
+ onSelect(index);
170
+ }}
171
+ className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-left text-[13px] leading-snug transition-colors ${
172
+ index === activeIndex ? 'bg-muted' : 'hover:bg-muted/50'
173
+ }`}
174
+ >
175
+ <SkillIcon />
176
+ <span className="shrink-0 font-semibold text-foreground">{item.name}</span>
177
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">
178
+ {item.description ?? ''}
179
+ </span>
180
+ <span className="shrink-0 pl-3 text-xs text-muted-foreground/80">
181
+ {ROOT_LABELS[item.root]}
182
+ </span>
183
+ </button>
184
+ ))}
185
+ </div>
186
+ </div>
187
+ );
188
+ };
189
+
190
+ const SkillIcon: React.FC = () => (
191
+ <svg
192
+ className="w-4 h-4 shrink-0 text-muted-foreground"
193
+ viewBox="0 0 24 24"
194
+ fill="none"
195
+ stroke="currentColor"
196
+ strokeWidth={1.7}
197
+ strokeLinecap="round"
198
+ strokeLinejoin="round"
199
+ aria-hidden="true"
200
+ >
201
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
202
+ <path d="M3.29 7 12 12l8.71-5" />
203
+ <path d="M12 22V12" />
204
+ </svg>
205
+ );
206
+
207
+ interface HumanOnlySkillNoticeProps {
208
+ skills: SkillCatalogEntry[];
209
+ }
210
+
211
+ /**
212
+ * Composer note while human-only skill references are present. Quiet by
213
+ * design: the user already chose to insert the reference (and saw the menu's
214
+ * disclosure while choosing), so the resting state is a single muted summary
215
+ * line rather than a standing explanation. The full plain-language sentence
216
+ * is one native disclosure away — <details>/<summary> keeps the expansion
217
+ * reachable by pointer, keyboard (the summary is focusable; Enter/Space
218
+ * toggles), and assistive tech alike. It pairs with the dotted-underline
219
+ * marker the composer overlay paints on human-only tokens.
220
+ */
221
+ export const HumanOnlySkillNotice: React.FC<HumanOnlySkillNoticeProps> = ({ skills }) => {
222
+ if (skills.length === 0) return null;
223
+ const names = skills.map((s) => s.name).join(', ');
224
+ return (
225
+ <details data-skill-human-only-notice="true" className="group mt-1 px-1">
226
+ <summary className="list-none [&::-webkit-details-marker]:hidden inline-flex cursor-pointer select-none items-center gap-1 rounded text-[10px] text-muted-foreground/80 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
227
+ <DisclosureChevron />
228
+ Includes skill instructions
229
+ </summary>
230
+ <p className="mt-0.5 pl-3.5 text-[11px] leading-snug text-muted-foreground">
231
+ {skills.length === 1 ? (
232
+ <>
233
+ <span className="font-mono">{names}</span> cannot be invoked by a model, so
234
+ its instructions will be included with your feedback.
235
+ </>
236
+ ) : (
237
+ <>
238
+ <span className="font-mono">{names}</span> cannot be invoked by a model, so
239
+ their instructions will be included with your feedback.
240
+ </>
241
+ )}
242
+ </p>
243
+ </details>
244
+ );
245
+ };
246
+
247
+ const DisclosureChevron: React.FC = () => (
248
+ <svg
249
+ className="w-2.5 h-2.5 shrink-0 transition-transform group-open:rotate-90"
250
+ viewBox="0 0 24 24"
251
+ fill="none"
252
+ stroke="currentColor"
253
+ strokeWidth={2}
254
+ strokeLinecap="round"
255
+ strokeLinejoin="round"
256
+ aria-hidden="true"
257
+ >
258
+ <path d="m9 18 6-6-6-6" />
259
+ </svg>
260
+ );
@@ -69,6 +69,9 @@ interface StickyHeaderLaneProps {
69
69
  isPlanDiffActive?: boolean;
70
70
  hasPreviousVersion?: boolean;
71
71
  onPlanDiffToggle?: () => void;
72
+ /** Baseline suffix + tooltip for the plan-diff badge (see DocBadges). */
73
+ planDiffBaselineLabel?: string;
74
+ planDiffBaselineTooltip?: string;
72
75
  archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
73
76
 
74
77
  // Layout
@@ -94,6 +97,8 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
94
97
  isPlanDiffActive,
95
98
  hasPreviousVersion,
96
99
  onPlanDiffToggle,
100
+ planDiffBaselineLabel,
101
+ planDiffBaselineTooltip,
97
102
  archiveInfo,
98
103
  maxWidth,
99
104
  remountToken,
@@ -251,6 +256,8 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
251
256
  isPlanDiffActive={isPlanDiffActive}
252
257
  hasPreviousVersion={hasPreviousVersion}
253
258
  onPlanDiffToggle={onPlanDiffToggle}
259
+ planDiffBaselineLabel={planDiffBaselineLabel}
260
+ planDiffBaselineTooltip={planDiffBaselineTooltip}
254
261
  archiveInfo={archiveInfo}
255
262
  />
256
263
  </div>