@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.
- 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
package/components/Settings.tsx
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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: '
|
|
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')
|
|
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>
|