@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,2220 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { Origin } from '@plannotator/core/agents';
4
+ import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
5
+ import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
6
+ import { loadDiffFont } from '../utils/diffFonts';
7
+ import { TaterSpritePullup } from './TaterSpritePullup';
8
+ import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
9
+ import { GitUser } from '../icons/GitUser';
10
+ import {
11
+ getObsidianSettings,
12
+ saveObsidianSettings,
13
+ getEffectiveVaultPath,
14
+ CUSTOM_PATH_SENTINEL,
15
+ DEFAULT_FILENAME_FORMAT,
16
+ type ObsidianSettings,
17
+ } from '../utils/obsidian';
18
+ import {
19
+ getBearSettings,
20
+ saveBearSettings,
21
+ normalizeTags,
22
+ type BearSettings,
23
+ } from '../utils/bear';
24
+ import {
25
+ getOctarineSettings,
26
+ saveOctarineSettings,
27
+ type OctarineSettings,
28
+ } from '../utils/octarine';
29
+ import {
30
+ getAgentSwitchSettings,
31
+ saveAgentSwitchSettings,
32
+ AGENT_OPTIONS,
33
+ type AgentSwitchSettings,
34
+ } from '../utils/agentSwitch';
35
+ import {
36
+ getPlanSaveSettings,
37
+ savePlanSaveSettings,
38
+ type PlanSaveSettings,
39
+ } from '../utils/planSave';
40
+ import {
41
+ getUIPreferences,
42
+ saveUIPreferences,
43
+ PLAN_WIDTH_OPTIONS,
44
+ type UIPreferences,
45
+ type PlanWidth,
46
+ } from '../utils/uiPreferences';
47
+ import {
48
+ getPermissionModeSettings,
49
+ savePermissionModeSettings,
50
+ PERMISSION_MODE_OPTIONS,
51
+ type PermissionMode,
52
+ } from '../utils/permissionMode';
53
+ import { getAutoCloseDelay, setAutoCloseDelay, AUTO_CLOSE_OPTIONS, type AutoCloseDelay } from '../utils/storage';
54
+ import {
55
+ getDefaultNotesApp,
56
+ saveDefaultNotesApp,
57
+ type DefaultNotesApp,
58
+ } from '../utils/defaultNotesApp';
59
+ import { useAgents } from '../hooks/useAgents';
60
+ import { KeyboardShortcuts } from './KeyboardShortcuts';
61
+ import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
62
+ import { ThemeTab } from './ThemeTab';
63
+ import { isMac, modKey, altKey } from '../utils/platform';
64
+ import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
65
+ import { AISettingsTab } from './AISettingsTab';
66
+ import { HooksTab } from './settings/HooksTab';
67
+ import { OverlayScrollArea } from './OverlayScrollArea';
68
+ import {
69
+ getFileBrowserSettings,
70
+ saveFileBrowserSettings,
71
+ type FileBrowserSettings,
72
+ } from '../utils/fileBrowser';
73
+
74
+ type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
75
+
76
+ interface SettingsProps {
77
+ taterMode: boolean;
78
+ onTaterModeChange: (enabled: boolean) => void;
79
+ onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
80
+ origin?: Origin | null;
81
+ mode?: 'plan' | 'annotate' | 'review';
82
+ onUIPreferencesChange?: (prefs: UIPreferences) => void;
83
+ /** Externally controlled open state (for mobile menu integration) */
84
+ externalOpen?: boolean;
85
+ onExternalClose?: () => void;
86
+ /** Available AI providers (from /api/ai/capabilities). */
87
+ aiProviders?: Array<{ id: string; name: string; capabilities: Record<string, boolean>; models?: Array<{ id: string; label: string; default?: boolean }> }>;
88
+ /** Git user name from `git config user.name`, for quick identity set */
89
+ gitUser?: string;
90
+ /** Current session is a local git review where since-base ISN'T offered
91
+ * (base ref unresolvable) — the Git tab shows a note that the Git-status
92
+ * preference can't take effect in THIS repo. */
93
+ sinceBaseUnavailable?: boolean;
94
+ /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */
95
+ onDetectObsidianVaults?: () => Promise<string[]>;
96
+ }
97
+
98
+ // --- Review-mode Display tab (diff display options) ---
99
+
100
+ const DIFF_FONT_OPTIONS = [
101
+ { value: '', label: 'Theme Default' },
102
+ { value: 'Fira Code', label: 'Fira Code' },
103
+ { value: 'Hack', label: 'Hack' },
104
+ { value: 'IBM Plex Mono', label: 'IBM Plex Mono' },
105
+ { value: 'Inconsolata', label: 'Inconsolata' },
106
+ { value: 'JetBrains Mono', label: 'JetBrains Mono' },
107
+ { value: 'Red Hat Mono', label: 'Red Hat Mono' },
108
+ { value: 'Roboto Mono', label: 'Roboto Mono' },
109
+ { value: 'Source Code Pro', label: 'Source Code Pro' },
110
+ { value: 'Atkinson Hyperlegible Mono', label: 'Atkinson Hyperlegible' },
111
+ ];
112
+
113
+ export const DIFF_STYLE_OPTIONS = [
114
+ { value: 'split' as const, label: 'Split' },
115
+ { value: 'unified' as const, label: 'Unified' },
116
+ ];
117
+ export const OVERFLOW_OPTIONS = [
118
+ { value: 'scroll' as const, label: 'Scroll' },
119
+ { value: 'wrap' as const, label: 'Wrap' },
120
+ ];
121
+ export const INDICATOR_OPTIONS = [
122
+ { value: 'bars' as const, label: 'Bars' },
123
+ { value: 'classic' as const, label: 'Classic' },
124
+ { value: 'none' as const, label: 'None' },
125
+ ];
126
+ export const LINE_DIFF_OPTIONS = [
127
+ { value: 'word-alt' as const, label: 'Word-Alt' },
128
+ { value: 'word' as const, label: 'Word' },
129
+ { value: 'char' as const, label: 'Char' },
130
+ { value: 'none' as const, label: 'None' },
131
+ ];
132
+ export const LINE_BG_INTENSITY_OPTIONS: { value: DiffLineBgIntensity; label: string }[] = [
133
+ { value: 'subtle', label: 'Subtle' },
134
+ { value: 'normal', label: 'Normal' },
135
+ { value: 'strong', label: 'Strong' },
136
+ ];
137
+ const DEFAULT_DIFF_TYPE_OPTIONS = [
138
+ // "All Changes" belongs to since-base (the flagship composite); uncommitted
139
+ // reverts to its plain name so the two stay distinguishable side by side.
140
+ { value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
141
+ { value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
142
+ { value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
143
+ { value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
144
+ { value: 'merge-base' as const, label: 'Committed changes (PR view)', description: "Everything you've committed on this branch" },
145
+ { value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
146
+ ];
147
+
148
+ function SegmentedControl<T extends string>({ options, value, onChange }: {
149
+ options: { value: T; label: string }[];
150
+ value: T;
151
+ onChange: (v: T) => void;
152
+ }) {
153
+ return (
154
+ <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
155
+ {options.map((opt) => (
156
+ <button
157
+ key={opt.value}
158
+ onClick={() => onChange(opt.value)}
159
+ className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
160
+ value === opt.value
161
+ ? 'bg-background text-foreground shadow-sm font-medium'
162
+ : 'text-muted-foreground hover:text-foreground'
163
+ }`}
164
+ >
165
+ {opt.label}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function ToggleSwitch({ checked, onChange, label, description }: {
173
+ checked: boolean;
174
+ onChange: (v: boolean) => void;
175
+ label: string;
176
+ description?: string;
177
+ }) {
178
+ return (
179
+ <div className="flex items-center justify-between">
180
+ <div>
181
+ <div className="text-sm font-medium">{label}</div>
182
+ {description && <div className="text-xs text-muted-foreground">{description}</div>}
183
+ </div>
184
+ <button
185
+ role="switch"
186
+ aria-checked={checked}
187
+ onClick={() => onChange(!checked)}
188
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
189
+ checked ? 'bg-primary' : 'bg-muted'
190
+ }`}
191
+ >
192
+ <span
193
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
194
+ checked ? 'translate-x-6' : 'translate-x-1'
195
+ }`}
196
+ />
197
+ </button>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
203
+ const defaultDiffType = useConfigValue('defaultDiffType');
204
+ const reviewPanelView = useConfigValue('reviewPanelView');
205
+ return (
206
+ <div className="space-y-5">
207
+ <div className="space-y-2">
208
+ <div>
209
+ <div className="text-sm font-medium">Default review view</div>
210
+ <div className="text-xs text-muted-foreground">Which panel a code review opens in</div>
211
+ {/* This is a GLOBAL preference — never hide the options because the
212
+ CURRENT repo can't serve them; just say so. Without this note,
213
+ picking Git status on a repo whose base ref doesn't resolve
214
+ silently falls back to Tree and the setting looks broken. */}
215
+ {sinceBaseUnavailable && (
216
+ <div className="text-xs text-warning mt-1">
217
+ Git status view isn't available in this repository (its base branch
218
+ couldn't be resolved) — reviews here open in Tree. The preference
219
+ still applies in repositories where it works.
220
+ </div>
221
+ )}
222
+ </div>
223
+ {/* No Commits option here: the Commits view is session-only (entered
224
+ via the panel toggle) and is never the opening view. */}
225
+ <SegmentedControl
226
+ options={[
227
+ { value: 'sections' as const, label: 'Git status' },
228
+ { value: 'tree' as const, label: 'Tree' },
229
+ ]}
230
+ value={reviewPanelView}
231
+ onChange={setReviewPanelView}
232
+ />
233
+ </div>
234
+ <div className="space-y-2">
235
+ <div>
236
+ <div className="text-sm font-medium">Default Diff View</div>
237
+ <div className="text-xs text-muted-foreground">Which changes to show when you open a code review</div>
238
+ </div>
239
+ <div className="space-y-2">
240
+ {DEFAULT_DIFF_TYPE_OPTIONS.map((opt) => (
241
+ <button
242
+ key={opt.value}
243
+ type="button"
244
+ // Coupling (sections ⟺ since-base) lives in the shared setter —
245
+ // never write the pair by hand (see config/reviewView).
246
+ onClick={() => setReviewDefaultDiffType(opt.value)}
247
+ className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
248
+ defaultDiffType === opt.value
249
+ ? 'border-primary bg-primary/5'
250
+ : 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
251
+ }`}
252
+ >
253
+ <div className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
254
+ defaultDiffType === opt.value ? 'border-primary' : 'border-muted-foreground/40'
255
+ }`}>
256
+ {defaultDiffType === opt.value && (
257
+ <div className="w-2 h-2 rounded-full bg-primary" />
258
+ )}
259
+ </div>
260
+ <div>
261
+ <div className="text-sm font-medium">{opt.label}</div>
262
+ <div className="text-xs text-muted-foreground">{opt.description}</div>
263
+ </div>
264
+ </button>
265
+ ))}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ );
270
+ };
271
+
272
+ const ReviewDisplayTab: React.FC = () => {
273
+ const diffStyle = useConfigValue('diffStyle');
274
+ const diffOverflow = useConfigValue('diffOverflow');
275
+ const diffIndicators = useConfigValue('diffIndicators');
276
+ const diffLineDiffType = useConfigValue('diffLineDiffType');
277
+ const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
278
+ const diffShowBackground = useConfigValue('diffShowBackground');
279
+ const diffLineBgIntensity = useConfigValue('diffLineBgIntensity');
280
+ const diffHideWhitespace = useConfigValue('diffHideWhitespace');
281
+ const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
282
+ const diffFontFamily = useConfigValue('diffFontFamily');
283
+ const diffFontSize = useConfigValue('diffFontSize');
284
+
285
+ // Load font for the preview swatch
286
+ useEffect(() => {
287
+ if (diffFontFamily) loadDiffFont(diffFontFamily);
288
+ }, [diffFontFamily]);
289
+
290
+ return (
291
+ <>
292
+ {/* Font Family */}
293
+ <div className="space-y-2">
294
+ <div>
295
+ <div className="text-sm font-medium">Code Font</div>
296
+ <div className="text-xs text-muted-foreground">Font family for diff code lines</div>
297
+ </div>
298
+ <select
299
+ value={diffFontFamily}
300
+ onChange={(e) => configStore.set('diffFontFamily', e.target.value)}
301
+ className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground"
302
+ style={diffFontFamily ? { fontFamily: `'${diffFontFamily}', monospace` } : undefined}
303
+ >
304
+ {DIFF_FONT_OPTIONS.map((opt) => (
305
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
306
+ ))}
307
+ </select>
308
+ {diffFontFamily && (
309
+ <div
310
+ className="text-xs text-muted-foreground px-1 py-1 rounded bg-muted/30 font-mono"
311
+ style={{ fontFamily: `'${diffFontFamily}', monospace` }}
312
+ >
313
+ Preview: const x = fn(42);
314
+ </div>
315
+ )}
316
+ </div>
317
+
318
+ <div className="border-t border-border" />
319
+
320
+ {/* Font Size */}
321
+ <div className="space-y-2">
322
+ <div className="flex items-center justify-between">
323
+ <div>
324
+ <div className="text-sm font-medium">Code Font Size</div>
325
+ <div className="text-xs text-muted-foreground">Font size for diff code lines</div>
326
+ </div>
327
+ <div className="text-xs tabular-nums text-muted-foreground min-w-[4ch] text-right">
328
+ {diffFontSize || 'Auto'}
329
+ </div>
330
+ </div>
331
+ <div className="flex items-center gap-2">
332
+ <input
333
+ type="range"
334
+ min={8}
335
+ max={24}
336
+ step={1}
337
+ value={diffFontSize ? parseInt(diffFontSize) : 13}
338
+ onChange={(e) => configStore.set('diffFontSize', `${e.target.value}px`)}
339
+ className="flex-1 h-1.5 accent-primary cursor-pointer"
340
+ />
341
+ {diffFontSize && (
342
+ <button
343
+ onClick={() => configStore.set('diffFontSize', '')}
344
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
345
+ >
346
+ Reset
347
+ </button>
348
+ )}
349
+ </div>
350
+ </div>
351
+
352
+ <div className="border-t border-border" />
353
+
354
+ {/* Diff Style */}
355
+ <div className="space-y-2">
356
+ <div>
357
+ <div className="text-sm font-medium">Diff Style</div>
358
+ <div className="text-xs text-muted-foreground">Side-by-side or inline diff view</div>
359
+ </div>
360
+ <SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
361
+ </div>
362
+
363
+ <div className="border-t border-border" />
364
+
365
+ {/* Line Overflow */}
366
+ <div className="space-y-2">
367
+ <div>
368
+ <div className="text-sm font-medium">Line Overflow</div>
369
+ <div className="text-xs text-muted-foreground">How to handle long lines in diffs</div>
370
+ </div>
371
+ <SegmentedControl options={OVERFLOW_OPTIONS} value={diffOverflow} onChange={(v) => configStore.set('diffOverflow', v)} />
372
+ </div>
373
+
374
+ <div className="border-t border-border" />
375
+
376
+ {/* Change Indicators */}
377
+ <div className="space-y-2">
378
+ <div>
379
+ <div className="text-sm font-medium">Change Indicators</div>
380
+ <div className="text-xs text-muted-foreground">Style of +/- markers in the gutter</div>
381
+ </div>
382
+ <SegmentedControl options={INDICATOR_OPTIONS} value={diffIndicators} onChange={(v) => configStore.set('diffIndicators', v)} />
383
+ </div>
384
+
385
+ <div className="border-t border-border" />
386
+
387
+ {/* Inline Diff Granularity */}
388
+ <div className="space-y-2">
389
+ <div>
390
+ <div className="text-sm font-medium">Inline Diff Granularity</div>
391
+ <div className="text-xs text-muted-foreground">Highlight granularity for inline changes</div>
392
+ </div>
393
+ <SegmentedControl options={LINE_DIFF_OPTIONS} value={diffLineDiffType} onChange={(v) => configStore.set('diffLineDiffType', v)} />
394
+ </div>
395
+
396
+ <div className="border-t border-border" />
397
+
398
+ {/* Show Line Numbers */}
399
+ <ToggleSwitch
400
+ checked={diffShowLineNumbers}
401
+ onChange={(v) => configStore.set('diffShowLineNumbers', v)}
402
+ label="Show Line Numbers"
403
+ />
404
+
405
+ <div className="border-t border-border" />
406
+
407
+ {/* Show Diff Background */}
408
+ <ToggleSwitch
409
+ checked={diffShowBackground}
410
+ onChange={(v) => configStore.set('diffShowBackground', v)}
411
+ label="Show Diff Background"
412
+ description="Colored backgrounds on added/deleted lines"
413
+ />
414
+
415
+ {/* Line Background Intensity */}
416
+ {diffShowBackground && (
417
+ <div className="space-y-2 pl-4">
418
+ <div>
419
+ <div className="text-sm font-medium">Line Background Intensity</div>
420
+ <div className="text-xs text-muted-foreground">How prominent the colored line backgrounds appear</div>
421
+ </div>
422
+ <SegmentedControl options={LINE_BG_INTENSITY_OPTIONS} value={diffLineBgIntensity} onChange={(v) => configStore.set('diffLineBgIntensity', v)} />
423
+ </div>
424
+ )}
425
+
426
+ <div className="border-t border-border" />
427
+
428
+ {/* Expand Unchanged Regions */}
429
+ <ToggleSwitch
430
+ checked={diffExpandUnchanged}
431
+ onChange={(v) => configStore.set('diffExpandUnchanged', v)}
432
+ label="Expand Unchanged Regions"
433
+ description="Show full file content around changes by default"
434
+ />
435
+
436
+ <div className="border-t border-border" />
437
+
438
+ {/* Hide Whitespace */}
439
+ <ToggleSwitch
440
+ checked={diffHideWhitespace}
441
+ onChange={(v) => configStore.set('diffHideWhitespace', v)}
442
+ label="Hide Whitespace"
443
+ description="Ignore whitespace-only changes in diffs"
444
+ />
445
+
446
+ </>
447
+ );
448
+ };
449
+
450
+ /**
451
+ * Stored label config — serialized to JSON in the config store.
452
+ * `blocking` controls whether the blocking/non-blocking toggle
453
+ * appears in the picker when this label is selected.
454
+ */
455
+ interface CCLabelConfig {
456
+ label: string;
457
+ display: string;
458
+ blocking: boolean; // true = show toggle in picker, false = no decoration
459
+ }
460
+
461
+ const DEFAULT_CC_LABELS: CCLabelConfig[] = [
462
+ { label: 'suggestion', display: 'suggestion', blocking: true },
463
+ { label: 'nitpick', display: 'nit', blocking: false },
464
+ { label: 'question', display: 'question', blocking: true },
465
+ { label: 'issue', display: 'issue', blocking: true },
466
+ { label: 'praise', display: 'praise', blocking: false },
467
+ { label: 'thought', display: 'thought', blocking: false },
468
+ { label: 'note', display: 'note', blocking: false },
469
+ { label: 'todo', display: 'todo', blocking: true },
470
+ { label: 'chore', display: 'chore', blocking: true },
471
+ ];
472
+
473
+ function parseCCLabels(json: string | null): CCLabelConfig[] {
474
+ if (!json) return DEFAULT_CC_LABELS;
475
+ try {
476
+ const parsed = JSON.parse(json);
477
+ if (!Array.isArray(parsed)) return DEFAULT_CC_LABELS;
478
+ return parsed.map((l: Record<string, unknown>) => ({
479
+ label: (l.label as string) || 'custom',
480
+ display: (l.display as string) || (l.label as string) || 'custom',
481
+ blocking: l.blocking === true || l.blocking === 'true',
482
+ }));
483
+ } catch {
484
+ return DEFAULT_CC_LABELS;
485
+ }
486
+ }
487
+
488
+ const CommentsTab: React.FC = () => {
489
+ const conventionalComments = useConfigValue('conventionalComments');
490
+ const labelsJson = useConfigValue('conventionalLabels');
491
+ const [labels, setLabels] = useState(() => parseCCLabels(labelsJson));
492
+
493
+ const isDefault = labelsJson === null;
494
+
495
+ const save = (next: CCLabelConfig[]) => {
496
+ setLabels(next);
497
+ configStore.set('conventionalLabels', JSON.stringify(next));
498
+ };
499
+
500
+ const updateLabel = (index: number, updates: Partial<CCLabelConfig>) => {
501
+ const next = [...labels];
502
+ next[index] = { ...next[index], ...updates };
503
+ save(next);
504
+ };
505
+
506
+ const removeLabel = (index: number) => {
507
+ save(labels.filter((_, i) => i !== index));
508
+ };
509
+
510
+ const addLabel = () => {
511
+ const existing = new Set(labels.map(l => l.label));
512
+ let slug = 'custom';
513
+ let n = 2;
514
+ while (existing.has(slug)) { slug = `custom-${n++}`; }
515
+ save([...labels, { label: slug, display: slug, blocking: false }]);
516
+ };
517
+
518
+ const resetToDefaults = () => {
519
+ setLabels(DEFAULT_CC_LABELS);
520
+ configStore.set('conventionalLabels', null);
521
+ };
522
+
523
+ return (
524
+ <>
525
+ <ToggleSwitch
526
+ checked={conventionalComments}
527
+ onChange={(v) => configStore.set('conventionalComments', v)}
528
+ label="Conventional Comments"
529
+ description="Add structured labels to review comments"
530
+ />
531
+
532
+ <div className={`space-y-4 transition-opacity ${conventionalComments ? '' : 'opacity-40 pointer-events-none'}`}>
533
+
534
+ <div className="border-t border-border" />
535
+
536
+ {/* How it works */}
537
+ <div className="space-y-3">
538
+ <div>
539
+ <div className="text-sm font-medium">How it works</div>
540
+ <div className="text-xs text-muted-foreground leading-relaxed mt-1">
541
+ When enabled, a label picker appears above the comment input when annotating code.
542
+ Labels classify your feedback intent, making it clear whether a comment is a blocking issue,
543
+ a trivial nitpick, or praise.
544
+ </div>
545
+ </div>
546
+
547
+ <div className="text-xs text-muted-foreground leading-relaxed">
548
+ Based on the{' '}
549
+ <a
550
+ href="https://conventionalcomments.org"
551
+ target="_blank"
552
+ rel="noopener noreferrer"
553
+ className="text-primary hover:underline"
554
+ >
555
+ Conventional Comments
556
+ </a>
557
+ {' '}spec. Comments are exported as plain text, readable on GitHub, and parseable by tooling.
558
+ </div>
559
+
560
+ {/* Example output */}
561
+ <div className="rounded-md border border-border bg-muted/30 p-3 space-y-1.5">
562
+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Example output</div>
563
+ <div className="font-mono text-[11px] text-foreground/80 leading-relaxed">
564
+ <span className="font-bold">issue</span> <span className="text-muted-foreground">(blocking)</span>: This will throw if user is null — the guard was removed in the refactor.
565
+ </div>
566
+ </div>
567
+ </div>
568
+
569
+ <div className="border-t border-border" />
570
+
571
+ {/* Label editor */}
572
+ <div className="flex items-center justify-between">
573
+ <div>
574
+ <div className="text-sm font-medium">Labels</div>
575
+ <div className="text-xs text-muted-foreground">
576
+ Customize labels and their default severity
577
+ </div>
578
+ </div>
579
+ {!isDefault && (
580
+ <button
581
+ onClick={resetToDefaults}
582
+ className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
583
+ >
584
+ Reset to defaults
585
+ </button>
586
+ )}
587
+ </div>
588
+
589
+ {/* Column headers */}
590
+ <div className="flex items-end gap-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
591
+ <span className="flex-1">Label</span>
592
+ <span className="w-20 text-center">Blocking decorator</span>
593
+ <span className="w-6" />
594
+ </div>
595
+
596
+ <div className="space-y-1.5">
597
+ {labels.map((label, index) => (
598
+ <div
599
+ key={index}
600
+ className="flex items-center gap-2 p-2 rounded-lg bg-muted/40"
601
+ >
602
+ <input
603
+ type="text"
604
+ value={label.display}
605
+ onChange={(e) => {
606
+ const display = e.target.value;
607
+ updateLabel(index, { display, label: display });
608
+ }}
609
+ className="flex-1 px-2 py-1 bg-background/80 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0"
610
+ />
611
+ <div className="w-20 flex justify-center">
612
+ <button
613
+ role="switch"
614
+ aria-checked={label.blocking}
615
+ onClick={() => updateLabel(index, { blocking: !label.blocking })}
616
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
617
+ label.blocking ? 'bg-primary' : 'bg-muted'
618
+ }`}
619
+ >
620
+ <span
621
+ className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm transition-transform ${
622
+ label.blocking ? 'translate-x-4.5' : 'translate-x-0.5'
623
+ }`}
624
+ />
625
+ </button>
626
+ </div>
627
+ <button
628
+ onClick={() => removeLabel(index)}
629
+ className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
630
+ title="Remove label"
631
+ >
632
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
633
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
634
+ </svg>
635
+ </button>
636
+ </div>
637
+ ))}
638
+ </div>
639
+
640
+ {labels.length < 12 && (
641
+ <button
642
+ onClick={addLabel}
643
+ className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
644
+ >
645
+ + Add label
646
+ </button>
647
+ )}
648
+
649
+ </div>
650
+ </>
651
+ );
652
+ };
653
+
654
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => {
655
+ const [showDialog, setShowDialog] = useState(false);
656
+ const [themePreview, setThemePreview] = useState(false);
657
+
658
+ useEffect(() => {
659
+ if (!themePreview) return;
660
+ const handler = (e: KeyboardEvent) => {
661
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setThemePreview(false); setShowDialog(true); }
662
+ };
663
+ document.addEventListener('keydown', handler);
664
+ return () => document.removeEventListener('keydown', handler);
665
+ }, [themePreview]);
666
+ const [activeTab, setActiveTab] = useState<SettingsTab>('general');
667
+ const gridEnabled = useConfigValue('gridEnabled');
668
+ const [identity, setIdentity] = useState('');
669
+ const [obsidian, setObsidian] = useState<ObsidianSettings>({
670
+ enabled: false,
671
+ vaultPath: '',
672
+ folder: 'plannotator',
673
+ filenameSeparator: 'space',
674
+ autoSave: false,
675
+ vaultBrowserEnabled: false,
676
+ });
677
+ const [detectedVaults, setDetectedVaults] = useState<string[]>([]);
678
+ const [vaultsLoading, setVaultsLoading] = useState(false);
679
+ const [bear, setBear] = useState<BearSettings>({ enabled: false, customTags: '', tagPosition: 'append', autoSave: false });
680
+ const [octarine, setOctarine] = useState<OctarineSettings>({ enabled: false, workspace: '', folder: 'plannotator', autoSave: false });
681
+ const [agent, setAgent] = useState<AgentSwitchSettings>({ switchTo: 'build' });
682
+ const [planSave, setPlanSave] = useState<PlanSaveSettings>({ enabled: true, customPath: null });
683
+ const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true, planWidth: 'compact' });
684
+ const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
685
+ const [agentWarning, setAgentWarning] = useState<string | null>(null);
686
+ const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
687
+ const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
688
+ const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
689
+ const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
690
+ const [editingTipValue, setEditingTipValue] = useState('');
691
+ const [aiProvider, setAiProvider] = useState<string | null>(null);
692
+ const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
693
+ const [newDirPath, setNewDirPath] = useState('');
694
+
695
+ // Fetch available agents for OpenCode
696
+ const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null);
697
+
698
+ const mainTabs = useMemo(() => {
699
+ const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }];
700
+ t.push({ id: 'theme', label: 'Theme' });
701
+ if (mode === 'plan') {
702
+ t.push({ id: 'display', label: 'Display' });
703
+ t.push({ id: 'saving', label: 'Saving' });
704
+ t.push({ id: 'labels', label: 'Labels' });
705
+ }
706
+ if (mode === 'review') {
707
+ t.push({ id: 'git', label: 'Git' });
708
+ t.push({ id: 'display', label: 'Display' });
709
+ t.push({ id: 'comments', label: 'Comments' });
710
+ if (aiProviders.length > 0) {
711
+ t.push({ id: 'ai', label: 'AI' });
712
+ }
713
+ }
714
+ t.push({ id: 'shortcuts', label: 'Shortcuts' });
715
+ if (mode === 'plan') {
716
+ t.push({ id: 'hooks', label: 'Hooks' });
717
+ }
718
+ return t;
719
+ }, [mode, aiProviders.length]);
720
+
721
+ const integrationTabs: { id: SettingsTab; label: string }[] = [
722
+ { id: 'files', label: 'Files' },
723
+ ...(mode === 'plan'
724
+ ? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }]
725
+ : []),
726
+ ];
727
+ const obsidianDefaultSaveAvailable = obsidian.enabled && getEffectiveVaultPath(obsidian).trim().length > 0;
728
+ const bearDefaultSaveAvailable = bear.enabled;
729
+ const octarineDefaultSaveAvailable = octarine.enabled && octarine.workspace.trim().length > 0;
730
+
731
+ // Sync external open state
732
+ useEffect(() => {
733
+ if (externalOpen) {
734
+ setShowDialog(true);
735
+ onExternalClose?.();
736
+ }
737
+ }, [externalOpen, onExternalClose]);
738
+
739
+ useEffect(() => {
740
+ if (showDialog) {
741
+ setIdentity(getIdentity())
742
+ setObsidian(getObsidianSettings());
743
+ setBear(getBearSettings());
744
+ setOctarine(getOctarineSettings());
745
+ setAgent(getAgentSwitchSettings());
746
+ setPlanSave(getPlanSaveSettings());
747
+ setUiPrefs(getUIPreferences());
748
+ setPermissionMode(getPermissionModeSettings().mode);
749
+ setAutoCloseDelayState(getAutoCloseDelay());
750
+ setDefaultNotesApp(getDefaultNotesApp());
751
+ setQuickLabelsState(getQuickLabels());
752
+ const aiSettings = getAIProviderSettings();
753
+ setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId);
754
+ setFileBrowserSettings(getFileBrowserSettings());
755
+
756
+ // Validate agent setting when dialog opens
757
+ if (origin === 'opencode') {
758
+ setAgentWarning(getAgentWarning());
759
+ }
760
+ }
761
+ }, [showDialog, availableAgents, origin, getAgentWarning, aiProviders.length]);
762
+
763
+ useEffect(() => {
764
+ if (!showDialog) return;
765
+
766
+ const defaultSaveAvailable =
767
+ defaultNotesApp === 'ask' ||
768
+ defaultNotesApp === 'download' ||
769
+ (defaultNotesApp === 'obsidian' && obsidianDefaultSaveAvailable) ||
770
+ (defaultNotesApp === 'bear' && bearDefaultSaveAvailable) ||
771
+ (defaultNotesApp === 'octarine' && octarineDefaultSaveAvailable);
772
+
773
+ if (!defaultSaveAvailable) {
774
+ setDefaultNotesApp('ask');
775
+ saveDefaultNotesApp('ask');
776
+ }
777
+ }, [
778
+ showDialog,
779
+ defaultNotesApp,
780
+ obsidianDefaultSaveAvailable,
781
+ bearDefaultSaveAvailable,
782
+ octarineDefaultSaveAvailable,
783
+ ]);
784
+
785
+ // Fetch detected vaults when Obsidian is enabled
786
+ useEffect(() => {
787
+ if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) {
788
+ setVaultsLoading(true);
789
+ const detect = onDetectObsidianVaults
790
+ ?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || []));
791
+ detect()
792
+ .then((vaults: string[]) => {
793
+ setDetectedVaults(vaults || []);
794
+ // Auto-select first vault if none set
795
+ if (vaults?.length > 0 && !obsidian.vaultPath) {
796
+ handleObsidianChange({ vaultPath: vaults[0] });
797
+ }
798
+ })
799
+ .catch(() => setDetectedVaults([]))
800
+ .finally(() => setVaultsLoading(false));
801
+ }
802
+ }, [obsidian.enabled]);
803
+
804
+ const handleFileBrowserChange = (updates: Partial<FileBrowserSettings>) => {
805
+ const newSettings = { ...fileBrowserSettings, ...updates };
806
+ setFileBrowserSettings(newSettings);
807
+ saveFileBrowserSettings(newSettings);
808
+ if (onUIPreferencesChange) onUIPreferencesChange({ ...uiPrefs });
809
+ };
810
+
811
+ const addDirectory = () => {
812
+ const trimmed = newDirPath.trim();
813
+ if (trimmed && !fileBrowserSettings.directories.includes(trimmed)) {
814
+ handleFileBrowserChange({
815
+ directories: [...fileBrowserSettings.directories, trimmed],
816
+ });
817
+ }
818
+ setNewDirPath('');
819
+ };
820
+
821
+ const handleObsidianChange = (updates: Partial<ObsidianSettings>) => {
822
+ const newSettings = { ...obsidian, ...updates };
823
+ setObsidian(newSettings);
824
+ saveObsidianSettings(newSettings);
825
+ };
826
+
827
+ const handleBearChange = (updates: Partial<BearSettings>) => {
828
+ const newSettings = { ...bear, ...updates };
829
+ setBear(newSettings);
830
+ saveBearSettings(newSettings);
831
+ };
832
+
833
+ const handleOctarineChange = (updates: Partial<OctarineSettings>) => {
834
+ const newSettings = { ...octarine, ...updates };
835
+ setOctarine(newSettings);
836
+ saveOctarineSettings(newSettings);
837
+ };
838
+
839
+ const handleAgentChange = (switchTo: AgentSwitchSettings['switchTo'], customName?: string) => {
840
+ const newSettings = { switchTo, customName: customName ?? agent.customName };
841
+ setAgent(newSettings);
842
+ saveAgentSwitchSettings(newSettings);
843
+ };
844
+
845
+ const handlePlanSaveChange = (updates: Partial<PlanSaveSettings>) => {
846
+ const newSettings = { ...planSave, ...updates };
847
+ setPlanSave(newSettings);
848
+ savePlanSaveSettings(newSettings);
849
+ };
850
+
851
+ const handleUIPrefsChange = (updates: Partial<UIPreferences>) => {
852
+ const newPrefs = { ...uiPrefs, ...updates };
853
+ setUiPrefs(newPrefs);
854
+ saveUIPreferences(newPrefs);
855
+ onUIPreferencesChange?.(newPrefs);
856
+ };
857
+
858
+ const handlePermissionModeChange = (mode: PermissionMode) => {
859
+ setPermissionMode(mode);
860
+ savePermissionModeSettings(mode);
861
+ };
862
+
863
+ const handleDefaultNotesAppChange = (app: DefaultNotesApp) => {
864
+ setDefaultNotesApp(app);
865
+ saveDefaultNotesApp(app);
866
+ };
867
+
868
+ // Server write-back is handled automatically by configStore.set() (debounced POST /api/config)
869
+
870
+ const handleRegenerateIdentity = () => {
871
+ const oldIdentity = identity;
872
+ const newIdentity = regenerateIdentity();
873
+ setIdentity(newIdentity);
874
+ onIdentityChange?.(oldIdentity, newIdentity);
875
+ };
876
+
877
+ const handleIdentitySave = (newName: string) => {
878
+ const trimmed = newName.trim();
879
+ if (!trimmed || trimmed === identity) return;
880
+ const oldIdentity = identity;
881
+ const saved = setCustomIdentity(trimmed);
882
+ setIdentity(saved);
883
+ onIdentityChange?.(oldIdentity, saved);
884
+ };
885
+
886
+ const handleUseGitName = () => {
887
+ if (!gitUser) return;
888
+ handleIdentitySave(gitUser);
889
+ };
890
+
891
+ // When a host owns identity (e.g. a logged-in user whose author is server-stamped),
892
+ // hide the rename/regenerate controls so a local name can't diverge from it.
893
+ // Default (Plannotator cookie identity) is editable, so this is true and the
894
+ // controls render exactly as before.
895
+ const identityEditable = isIdentityEditable();
896
+
897
+ return (
898
+ <>
899
+ <button
900
+ onClick={() => setShowDialog(true)}
901
+ className="relative p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
902
+ title="Settings"
903
+ >
904
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
905
+ <path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
906
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
907
+ </svg>
908
+ </button>
909
+
910
+ {showDialog && !themePreview && createPortal(
911
+ <div
912
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
913
+ onClick={() => setShowDialog(false)}
914
+ >
915
+ <div
916
+ className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl relative overflow-hidden"
917
+ onClick={e => e.stopPropagation()}
918
+ >
919
+ {taterMode && <TaterSpritePullup />}
920
+ <div className="flex items-center justify-between p-4 border-b border-border">
921
+ <h3 className="font-semibold text-sm">Settings</h3>
922
+ <button
923
+ onClick={() => setShowDialog(false)}
924
+ className="p-1.5 rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors"
925
+ >
926
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
927
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
928
+ </svg>
929
+ </button>
930
+ </div>
931
+
932
+ <div className="flex flex-col md:flex-row md:min-h-[420px] flex-1 min-h-0 overflow-hidden">
933
+ {/* Mobile: horizontal tab bar */}
934
+ <nav className="md:hidden flex overflow-x-auto border-b border-border px-2 py-1.5 gap-1 flex-shrink-0">
935
+ {[...mainTabs, ...integrationTabs].map(tab => (
936
+ <button
937
+ key={tab.id}
938
+ onClick={() => setActiveTab(tab.id)}
939
+ className={`px-3 py-1.5 rounded text-xs whitespace-nowrap transition-colors flex items-center gap-1.5 ${
940
+ activeTab === tab.id
941
+ ? 'bg-primary/10 text-primary font-medium'
942
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
943
+ }`}
944
+ >
945
+ {tab.label}
946
+ </button>
947
+ ))}
948
+ </nav>
949
+
950
+ {/* Desktop: sidebar */}
951
+ <nav className="hidden md:block w-40 border-r border-border p-2 flex-shrink-0">
952
+ <div className="space-y-0.5">
953
+ {mainTabs.map(tab => (
954
+ <button
955
+ key={tab.id}
956
+ onClick={() => setActiveTab(tab.id)}
957
+ className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors flex items-center justify-between ${
958
+ activeTab === tab.id
959
+ ? 'bg-primary/10 text-primary font-medium'
960
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
961
+ }`}
962
+ >
963
+ {tab.label}
964
+ </button>
965
+ ))}
966
+ </div>
967
+ {integrationTabs.length > 0 && (
968
+ <>
969
+ <div className="mx-2 my-2 border-t border-border/50" />
970
+ <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
971
+ Integrations
972
+ </div>
973
+ <div className="space-y-0.5">
974
+ {integrationTabs.map(tab => (
975
+ <button
976
+ key={tab.id}
977
+ onClick={() => setActiveTab(tab.id)}
978
+ className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
979
+ activeTab === tab.id
980
+ ? 'bg-primary/10 text-primary font-medium'
981
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
982
+ }`}
983
+ >
984
+ {tab.label}
985
+ </button>
986
+ ))}
987
+ </div>
988
+ </>
989
+ )}
990
+ </nav>
991
+
992
+ {/* Content — scrollable */}
993
+ <OverlayScrollArea className="flex-1 min-h-0">
994
+ <div className="p-4 space-y-4">
995
+
996
+ {/* === GENERAL TAB === */}
997
+ {activeTab === 'general' && (
998
+ <>
999
+ {/* Identity */}
1000
+ <div className="space-y-2">
1001
+ <div className="text-sm font-medium">Your Identity</div>
1002
+ <div className="text-xs text-muted-foreground">
1003
+ Used when sharing annotations with others
1004
+ </div>
1005
+ <div className="flex items-center gap-2">
1006
+ {identityEditable ? (
1007
+ <input
1008
+ key={identity}
1009
+ type="text"
1010
+ defaultValue={identity}
1011
+ onBlur={(e) => handleIdentitySave(e.target.value)}
1012
+ onKeyDown={(e) => {
1013
+ if (e.key === 'Enter') {
1014
+ handleIdentitySave((e.target as HTMLInputElement).value);
1015
+ (e.target as HTMLInputElement).blur();
1016
+ }
1017
+ }}
1018
+ className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent focus:border-primary/50 focus:outline-none transition-colors"
1019
+ placeholder="Enter your name..."
1020
+ />
1021
+ ) : (
1022
+ <div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent">
1023
+ {identity}
1024
+ </div>
1025
+ )}
1026
+ {identityEditable && gitUser && (
1027
+ <button
1028
+ onClick={handleUseGitName}
1029
+ onMouseDown={(e) => e.preventDefault()}
1030
+ className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
1031
+ title={`Use git identity: ${gitUser}`}
1032
+ >
1033
+ <GitUser className="w-5 h-5" />
1034
+ </button>
1035
+ )}
1036
+ {identityEditable && (
1037
+ <button
1038
+ onClick={handleRegenerateIdentity}
1039
+ onMouseDown={(e) => e.preventDefault()}
1040
+ className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
1041
+ title="Regenerate random identity"
1042
+ >
1043
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1044
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
1045
+ </svg>
1046
+ </button>
1047
+ )}
1048
+ </div>
1049
+ </div>
1050
+
1051
+ {/* Permission Mode (Claude Code only) */}
1052
+ {origin === 'claude-code' && mode === 'plan' && (
1053
+ <>
1054
+ <div className="border-t border-border" />
1055
+ <div className="space-y-2">
1056
+ <div>
1057
+ <div className="text-sm font-medium">Permission Mode</div>
1058
+ <div className="text-xs text-muted-foreground">
1059
+ Automation level after plan approval
1060
+ </div>
1061
+ </div>
1062
+ <select
1063
+ value={permissionMode}
1064
+ onChange={(e) => handlePermissionModeChange(e.target.value as PermissionMode)}
1065
+ className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1066
+ >
1067
+ {PERMISSION_MODE_OPTIONS.map((option) => (
1068
+ <option key={option.value} value={option.value}>
1069
+ {option.label}
1070
+ </option>
1071
+ ))}
1072
+ </select>
1073
+ <div className="text-[10px] text-muted-foreground/70">
1074
+ {PERMISSION_MODE_OPTIONS.find(o => o.value === permissionMode)?.description}
1075
+ </div>
1076
+ </div>
1077
+ </>
1078
+ )}
1079
+
1080
+ {/* Agent Switching (OpenCode only) */}
1081
+ {origin === 'opencode' && (
1082
+ <>
1083
+ <div className="border-t border-border" />
1084
+ <div className="space-y-2">
1085
+ <div>
1086
+ <div className="text-sm font-medium">Agent Switching</div>
1087
+ <div className="text-xs text-muted-foreground">
1088
+ Which agent to switch to after plan approval
1089
+ </div>
1090
+ </div>
1091
+
1092
+ {agentWarning && (
1093
+ <div className="flex items-start gap-2 p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg text-xs text-amber-600 dark:text-amber-400">
1094
+ <svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1095
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
1096
+ </svg>
1097
+ <span>{agentWarning}</span>
1098
+ </div>
1099
+ )}
1100
+
1101
+ <select
1102
+ value={agent.switchTo}
1103
+ onChange={(e) => {
1104
+ handleAgentChange(e.target.value);
1105
+ setAgentWarning(null);
1106
+ }}
1107
+ className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1108
+ >
1109
+ {availableAgents.length > 0 ? (
1110
+ <>
1111
+ {agent.switchTo !== 'custom' &&
1112
+ agent.switchTo !== 'disabled' &&
1113
+ !availableAgents.some(a => a.id.toLowerCase() === agent.switchTo.toLowerCase()) && (
1114
+ <option value={agent.switchTo} disabled>
1115
+ {agent.switchTo} (not found)
1116
+ </option>
1117
+ )}
1118
+ {availableAgents.map((a) => (
1119
+ <option key={a.id} value={a.id}>
1120
+ {a.name}
1121
+ </option>
1122
+ ))}
1123
+ <option value="custom">Custom</option>
1124
+ <option value="disabled">Disabled</option>
1125
+ </>
1126
+ ) : (
1127
+ AGENT_OPTIONS.map((option) => (
1128
+ <option key={option.value} value={option.value}>
1129
+ {option.label}
1130
+ </option>
1131
+ ))
1132
+ )}
1133
+ </select>
1134
+ {agent.switchTo === 'custom' && (
1135
+ <input
1136
+ type="text"
1137
+ value={agent.customName || ''}
1138
+ onChange={(e) => {
1139
+ const customName = e.target.value;
1140
+ handleAgentChange('custom', customName);
1141
+ if (customName && availableAgents.length > 0) {
1142
+ if (!validateAgent(customName)) {
1143
+ setAgentWarning(`Agent "${customName}" not found in OpenCode. It may cause errors.`);
1144
+ } else {
1145
+ setAgentWarning(null);
1146
+ }
1147
+ } else {
1148
+ setAgentWarning(null);
1149
+ }
1150
+ }}
1151
+ placeholder="Enter agent name..."
1152
+ className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 placeholder:text-muted-foreground/50"
1153
+ />
1154
+ )}
1155
+ <div className="text-[10px] text-muted-foreground/70">
1156
+ {agent.switchTo === 'custom' && agent.customName
1157
+ ? `Switch to "${agent.customName}" agent after approval`
1158
+ : agent.switchTo === 'disabled'
1159
+ ? 'Stay on current agent after approval'
1160
+ : `Switch to ${agent.switchTo} agent after approval`}
1161
+ </div>
1162
+ </div>
1163
+ </>
1164
+ )}
1165
+
1166
+ <div className="border-t border-border" />
1167
+
1168
+ {/* Auto-close Tab */}
1169
+ <div className="space-y-2">
1170
+ <div className="text-sm font-medium">Auto-close Tab</div>
1171
+ <select
1172
+ value={autoCloseDelay}
1173
+ onChange={(e) => {
1174
+ const next = e.target.value as AutoCloseDelay;
1175
+ setAutoCloseDelayState(next);
1176
+ setAutoCloseDelay(next);
1177
+ }}
1178
+ className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1179
+ >
1180
+ {AUTO_CLOSE_OPTIONS.map((option) => (
1181
+ <option key={option.value} value={option.value}>
1182
+ {option.label}
1183
+ </option>
1184
+ ))}
1185
+ </select>
1186
+ <div className="text-[10px] text-muted-foreground/70">
1187
+ {AUTO_CLOSE_OPTIONS.find(o => o.value === autoCloseDelay)?.description}
1188
+ </div>
1189
+ </div>
1190
+ </>
1191
+ )}
1192
+
1193
+ {/* === THEME TAB === */}
1194
+ {activeTab === 'theme' && <ThemeTab onPreview={() => { setShowDialog(false); setThemePreview(true); }} />}
1195
+
1196
+ {/* === GIT TAB === */}
1197
+ {activeTab === 'git' && mode === 'review' && (
1198
+ <GitTab sinceBaseUnavailable={sinceBaseUnavailable} />
1199
+ )}
1200
+
1201
+ {/* === DISPLAY TAB === */}
1202
+ {activeTab === 'display' && mode === 'review' && (
1203
+ <ReviewDisplayTab />
1204
+ )}
1205
+
1206
+ {activeTab === 'display' && mode !== 'review' && (
1207
+ <>
1208
+ {/* Auto-open Sidebar */}
1209
+ <div className="flex items-center justify-between">
1210
+ <div>
1211
+ <div className="text-sm font-medium">Auto-open Sidebar</div>
1212
+ <div className="text-xs text-muted-foreground">
1213
+ Open sidebar with Table of Contents on load
1214
+ </div>
1215
+ </div>
1216
+ <button
1217
+ role="switch"
1218
+ aria-checked={uiPrefs.tocEnabled}
1219
+ onClick={() => handleUIPrefsChange({ tocEnabled: !uiPrefs.tocEnabled })}
1220
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1221
+ uiPrefs.tocEnabled ? 'bg-primary' : 'bg-muted'
1222
+ }`}
1223
+ >
1224
+ <span
1225
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1226
+ uiPrefs.tocEnabled ? 'translate-x-6' : 'translate-x-1'
1227
+ }`}
1228
+ />
1229
+ </button>
1230
+ </div>
1231
+
1232
+ <div className="border-t border-border" />
1233
+
1234
+ {/* Sticky Actions */}
1235
+ <div className="flex items-center justify-between">
1236
+ <div>
1237
+ <div className="text-sm font-medium">Sticky Actions</div>
1238
+ <div className="text-xs text-muted-foreground">
1239
+ Keep action buttons visible while scrolling
1240
+ </div>
1241
+ </div>
1242
+ <button
1243
+ role="switch"
1244
+ aria-checked={uiPrefs.stickyActionsEnabled}
1245
+ onClick={() => handleUIPrefsChange({ stickyActionsEnabled: !uiPrefs.stickyActionsEnabled })}
1246
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1247
+ uiPrefs.stickyActionsEnabled ? 'bg-primary' : 'bg-muted'
1248
+ }`}
1249
+ >
1250
+ <span
1251
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1252
+ uiPrefs.stickyActionsEnabled ? 'translate-x-6' : 'translate-x-1'
1253
+ }`}
1254
+ />
1255
+ </button>
1256
+ </div>
1257
+
1258
+ <div className="border-t border-border" />
1259
+
1260
+ {/* Grid Background */}
1261
+ <div className="flex items-center justify-between">
1262
+ <div>
1263
+ <div className="text-sm font-medium">Grid Background</div>
1264
+ <div className="text-xs text-muted-foreground">Show the plan as a floating card on a grid</div>
1265
+ </div>
1266
+ <button role="switch" aria-checked={gridEnabled}
1267
+ onClick={() => configStore.set('gridEnabled', !gridEnabled)}
1268
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${gridEnabled ? 'bg-primary' : 'bg-muted'}`}>
1269
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${gridEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
1270
+ </button>
1271
+ </div>
1272
+
1273
+ <div className="border-t border-border" />
1274
+
1275
+ {/* Plan Width */}
1276
+ <div className="space-y-3">
1277
+ <div>
1278
+ <div className="text-sm font-medium flex items-center gap-2">Plan Width</div>
1279
+ <div className="text-xs text-muted-foreground">
1280
+ Maximum width of the plan card
1281
+ </div>
1282
+ </div>
1283
+ <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
1284
+ {PLAN_WIDTH_OPTIONS.map((opt) => (
1285
+ <button
1286
+ key={opt.id}
1287
+ onClick={() => handleUIPrefsChange({ planWidth: opt.id })}
1288
+ className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
1289
+ uiPrefs.planWidth === opt.id
1290
+ ? 'bg-background text-foreground shadow-sm font-medium'
1291
+ : 'text-muted-foreground hover:text-foreground'
1292
+ }`}
1293
+ >
1294
+ {opt.label}
1295
+ </button>
1296
+ ))}
1297
+ </div>
1298
+
1299
+ {/* Abstract layout preview — exaggerated proportions for visual clarity */}
1300
+ {(() => {
1301
+ const active = PLAN_WIDTH_OPTIONS.find(o => o.id === uiPrefs.planWidth) ?? PLAN_WIDTH_OPTIONS[0];
1302
+ // Exaggerated proportions so the width difference is visually obvious in the small preview
1303
+ const sidebarPct = 14;
1304
+ const panelPct = 14;
1305
+ const cardPctMap: Record<PlanWidth, number> = { compact: 48, default: 70, wide: 94 };
1306
+ const cardPct = cardPctMap[active.id];
1307
+ return (
1308
+ <div className="space-y-2">
1309
+ <div className="rounded-lg border border-border/40 bg-muted/20 px-2 py-3 overflow-hidden">
1310
+ {/* Simulated header bar */}
1311
+ <div className="flex items-center justify-between mb-2 px-1">
1312
+ <div className="h-0.5 w-8 rounded-full bg-foreground/15" />
1313
+ <div className="flex gap-1">
1314
+ <div className="h-1 w-1 rounded-full bg-foreground/15" />
1315
+ <div className="h-1 w-1 rounded-full bg-foreground/15" />
1316
+ <div className="h-1 w-1 rounded-full bg-foreground/15" />
1317
+ </div>
1318
+ </div>
1319
+ <div className="border-t border-foreground/5 mb-2" />
1320
+ {/* Three-column layout */}
1321
+ <div className="flex gap-1 items-stretch" style={{ minHeight: 64 }}>
1322
+ {/* Sidebar */}
1323
+ <div className="flex-shrink-0 space-y-1 pt-0.5 opacity-30" style={{ width: `${sidebarPct}%` }}>
1324
+ <div className="h-0.5 w-full rounded-full bg-foreground" />
1325
+ <div className="h-0.5 w-3/4 rounded-full bg-foreground" />
1326
+ <div className="h-0.5 w-1/2 rounded-full bg-foreground" />
1327
+ <div className="h-0.5 w-2/3 rounded-full bg-foreground" />
1328
+ <div className="h-0.5 w-1/2 rounded-full bg-foreground" />
1329
+ </div>
1330
+ {/* Plan card — width animates */}
1331
+ <div className="flex-1 flex justify-center min-w-0">
1332
+ <div
1333
+ className="rounded border border-border/60 bg-card/50 p-1.5 space-y-1 transition-all duration-300 ease-out"
1334
+ style={{ width: `${cardPct}%`, minWidth: 0 }}
1335
+ >
1336
+ {/* Heading */}
1337
+ <div className="h-1 w-2/5 rounded-full bg-foreground/25" />
1338
+ {/* Prose lines */}
1339
+ <div className="space-y-[2px]">
1340
+ <div className="h-[2px] w-full rounded-full bg-foreground/10" />
1341
+ <div className="h-[2px] w-11/12 rounded-full bg-foreground/10" />
1342
+ <div className="h-[2px] w-4/5 rounded-full bg-foreground/10" />
1343
+ </div>
1344
+ {/* Code block */}
1345
+ <div className="rounded bg-muted/60 p-1 space-y-[2px]">
1346
+ <div className="h-[2px] w-full rounded-full bg-primary/20" />
1347
+ <div className="h-[2px] w-3/4 rounded-full bg-primary/20" />
1348
+ <div className="h-[2px] w-5/6 rounded-full bg-primary/20" />
1349
+ </div>
1350
+ {/* More prose */}
1351
+ <div className="space-y-[2px]">
1352
+ <div className="h-[2px] w-full rounded-full bg-foreground/10" />
1353
+ <div className="h-[2px] w-3/4 rounded-full bg-foreground/10" />
1354
+ </div>
1355
+ </div>
1356
+ </div>
1357
+ {/* Annotation panel */}
1358
+ <div className="flex-shrink-0 space-y-1 pt-0.5 opacity-20" style={{ width: `${panelPct}%` }}>
1359
+ <div className="rounded border border-foreground/20 p-0.5 space-y-[2px]">
1360
+ <div className="h-[2px] w-full rounded-full bg-foreground" />
1361
+ <div className="h-[2px] w-2/3 rounded-full bg-foreground" />
1362
+ </div>
1363
+ <div className="rounded border border-foreground/20 p-0.5 space-y-[2px]">
1364
+ <div className="h-[2px] w-full rounded-full bg-foreground" />
1365
+ <div className="h-[2px] w-1/2 rounded-full bg-foreground" />
1366
+ </div>
1367
+ </div>
1368
+ </div>
1369
+ </div>
1370
+ <div className="text-[10px] text-muted-foreground/70 leading-snug">
1371
+ {active.px}px — {active.hint}
1372
+ </div>
1373
+ </div>
1374
+ );
1375
+ })()}
1376
+ </div>
1377
+
1378
+ <div className="border-t border-border" />
1379
+
1380
+ {/* Tater Mode */}
1381
+ <div className="flex items-center justify-between">
1382
+ <div className="text-sm font-medium">Tater Mode</div>
1383
+ <button
1384
+ role="switch"
1385
+ aria-checked={taterMode}
1386
+ onClick={() => onTaterModeChange(!taterMode)}
1387
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1388
+ taterMode ? 'bg-primary' : 'bg-muted'
1389
+ }`}
1390
+ >
1391
+ <span
1392
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1393
+ taterMode ? 'translate-x-6' : 'translate-x-1'
1394
+ }`}
1395
+ />
1396
+ </button>
1397
+ </div>
1398
+ </>
1399
+ )}
1400
+
1401
+ {/* === SAVING TAB === */}
1402
+ {activeTab === 'saving' && (
1403
+ <>
1404
+ {/* Plan Saving */}
1405
+ <div className="space-y-3">
1406
+ <div className="flex items-center justify-between">
1407
+ <div>
1408
+ <div className="text-sm font-medium">Save Plans</div>
1409
+ <div className="text-xs text-muted-foreground">
1410
+ Auto-save plans to the default data directory
1411
+ </div>
1412
+ </div>
1413
+ <button
1414
+ role="switch"
1415
+ aria-checked={planSave.enabled}
1416
+ onClick={() => handlePlanSaveChange({ enabled: !planSave.enabled })}
1417
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1418
+ planSave.enabled ? 'bg-primary' : 'bg-muted'
1419
+ }`}
1420
+ >
1421
+ <span
1422
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1423
+ planSave.enabled ? 'translate-x-6' : 'translate-x-1'
1424
+ }`}
1425
+ />
1426
+ </button>
1427
+ </div>
1428
+
1429
+ {planSave.enabled && (
1430
+ <div className="space-y-1.5 pl-0.5">
1431
+ <label className="text-xs text-muted-foreground">Custom Path (optional)</label>
1432
+ <input
1433
+ type="text"
1434
+ value={planSave.customPath || ''}
1435
+ onChange={(e) => handlePlanSaveChange({ customPath: e.target.value || null })}
1436
+ placeholder="Leave empty for default"
1437
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1438
+ />
1439
+ <div className="text-[10px] text-muted-foreground/70">
1440
+ Leave empty to use default location
1441
+ </div>
1442
+ </div>
1443
+ )}
1444
+ </div>
1445
+
1446
+ <div className="border-t border-border" />
1447
+
1448
+ {/* Default Notes App */}
1449
+ <div className="space-y-2">
1450
+ <div>
1451
+ <div className="text-sm font-medium">Default Save Action</div>
1452
+ <div className="text-xs text-muted-foreground">
1453
+ Used for keyboard shortcut ({modKey}+S)
1454
+ </div>
1455
+ </div>
1456
+ <select
1457
+ value={defaultNotesApp}
1458
+ onChange={(e) => handleDefaultNotesAppChange(e.target.value as DefaultNotesApp)}
1459
+ className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1460
+ >
1461
+ <option value="ask">Ask each time</option>
1462
+ <option value="download">Download Annotations</option>
1463
+ {obsidianDefaultSaveAvailable && <option value="obsidian">Obsidian</option>}
1464
+ {bearDefaultSaveAvailable && <option value="bear">Bear</option>}
1465
+ {octarineDefaultSaveAvailable && <option value="octarine">Octarine</option>}
1466
+ </select>
1467
+ <div className="text-[10px] text-muted-foreground/70">
1468
+ {defaultNotesApp === 'ask'
1469
+ ? 'Opens Export dialog with Notes tab'
1470
+ : defaultNotesApp === 'download'
1471
+ ? `${modKey}+S downloads the annotations file`
1472
+ : `${modKey}+S saves directly to ${{ obsidian: 'Obsidian', bear: 'Bear', octarine: 'Octarine' }[defaultNotesApp] ?? defaultNotesApp}`}
1473
+ </div>
1474
+ </div>
1475
+
1476
+ <div className="border-t border-border" />
1477
+
1478
+ {/* Integration links */}
1479
+ <div className="space-y-2">
1480
+ <div className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
1481
+ Integrations
1482
+ </div>
1483
+ <button
1484
+ onClick={() => setActiveTab('obsidian')}
1485
+ className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
1486
+ >
1487
+ <span className="text-foreground">Obsidian</span>
1488
+ <span className="flex items-center gap-2">
1489
+ <span className={`text-[10px] font-medium ${obsidian.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
1490
+ {obsidian.enabled ? 'Enabled' : 'Off'}
1491
+ </span>
1492
+ <svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1493
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
1494
+ </svg>
1495
+ </span>
1496
+ </button>
1497
+ <button
1498
+ onClick={() => setActiveTab('bear')}
1499
+ className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
1500
+ >
1501
+ <span className="text-foreground">Bear Notes</span>
1502
+ <span className="flex items-center gap-2">
1503
+ <span className={`text-[10px] font-medium ${bear.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
1504
+ {bear.enabled ? 'Enabled' : 'Off'}
1505
+ </span>
1506
+ <svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1507
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
1508
+ </svg>
1509
+ </span>
1510
+ </button>
1511
+ <button
1512
+ onClick={() => setActiveTab('octarine')}
1513
+ className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
1514
+ >
1515
+ <span className="text-foreground">Octarine</span>
1516
+ <span className="flex items-center gap-2">
1517
+ <span className={`text-[10px] font-medium ${octarine.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
1518
+ {octarine.enabled ? 'Enabled' : 'Off'}
1519
+ </span>
1520
+ <svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1521
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
1522
+ </svg>
1523
+ </span>
1524
+ </button>
1525
+ </div>
1526
+ </>
1527
+ )}
1528
+
1529
+ {/* === LABELS TAB === */}
1530
+ {activeTab === 'labels' && (
1531
+ <>
1532
+ <div className="flex items-center justify-between">
1533
+ <div>
1534
+ <div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
1535
+ <div className="text-xs text-muted-foreground">
1536
+ Preset annotations for one-click feedback
1537
+ </div>
1538
+ </div>
1539
+ <button
1540
+ onClick={() => {
1541
+ resetQuickLabels();
1542
+ setQuickLabelsState(DEFAULT_QUICK_LABELS);
1543
+ }}
1544
+ className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
1545
+ >
1546
+ Reset to defaults
1547
+ </button>
1548
+ </div>
1549
+
1550
+ <style>{`
1551
+ @keyframes tip-slide-open {
1552
+ from { opacity: 0; transform: translateY(-4px); }
1553
+ to { opacity: 1; transform: translateY(0); }
1554
+ }
1555
+ `}</style>
1556
+ <div className="space-y-1.5">
1557
+ {quickLabelsState.map((label, index) => {
1558
+ const colors = getLabelColors(label.color);
1559
+ const hasTip = !!label.tip;
1560
+ const isEditingTip = editingTipIndex === index;
1561
+ return (
1562
+ <div key={index} className="rounded-lg overflow-hidden" style={{ backgroundColor: colors.bg }}>
1563
+ {/* Main row */}
1564
+ <div className="flex items-center gap-2 p-2">
1565
+ <span className="text-sm flex-shrink-0">{label.emoji}</span>
1566
+ <input
1567
+ type="text"
1568
+ value={label.text}
1569
+ onChange={(e) => {
1570
+ const updated = [...quickLabelsState];
1571
+ updated[index] = {
1572
+ ...label,
1573
+ text: e.target.value,
1574
+ id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
1575
+ };
1576
+ setQuickLabelsState(updated);
1577
+ saveQuickLabels(updated);
1578
+ }}
1579
+ className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
1580
+ />
1581
+ {/* Tip indicator button */}
1582
+ <button
1583
+ onClick={() => {
1584
+ if (isEditingTip) {
1585
+ setEditingTipIndex(null);
1586
+ } else {
1587
+ setEditingTipIndex(index);
1588
+ setEditingTipValue(label.tip || '');
1589
+ }
1590
+ }}
1591
+ className={`relative p-1 rounded transition-all flex-shrink-0 ${
1592
+ hasTip
1593
+ ? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
1594
+ : 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
1595
+ }`}
1596
+ title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
1597
+ >
1598
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1599
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
1600
+ </svg>
1601
+ {hasTip && (
1602
+ <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
1603
+ )}
1604
+ </button>
1605
+ <select
1606
+ value={label.color}
1607
+ onChange={(e) => {
1608
+ const updated = [...quickLabelsState];
1609
+ updated[index] = { ...label, color: e.target.value };
1610
+ setQuickLabelsState(updated);
1611
+ saveQuickLabels(updated);
1612
+ }}
1613
+ className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
1614
+ >
1615
+ {Object.keys(LABEL_COLOR_MAP).map(c => (
1616
+ <option key={c} value={c}>{c}</option>
1617
+ ))}
1618
+ </select>
1619
+ <span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
1620
+ {index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''}
1621
+ </span>
1622
+ <button
1623
+ onClick={() => {
1624
+ const updated = quickLabelsState.filter((_, i) => i !== index);
1625
+ setQuickLabelsState(updated);
1626
+ saveQuickLabels(updated);
1627
+ if (editingTipIndex === index) setEditingTipIndex(null);
1628
+ }}
1629
+ className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
1630
+ title="Remove label"
1631
+ >
1632
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1633
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1634
+ </svg>
1635
+ </button>
1636
+ </div>
1637
+ {/* Tip editor — slides open below the row */}
1638
+ {isEditingTip && (
1639
+ <div
1640
+ className="flex items-center gap-1.5 px-2 pb-2 pt-0"
1641
+ style={{ animation: 'tip-slide-open 0.15s ease-out' }}
1642
+ >
1643
+ <svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
1644
+ <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
1645
+ </svg>
1646
+ <input
1647
+ type="text"
1648
+ value={editingTipValue}
1649
+ onChange={(e) => setEditingTipValue(e.target.value)}
1650
+ onKeyDown={(e) => {
1651
+ if (e.key === 'Enter') {
1652
+ const updated = [...quickLabelsState];
1653
+ updated[index] = { ...label, tip: editingTipValue || undefined };
1654
+ setQuickLabelsState(updated);
1655
+ saveQuickLabels(updated);
1656
+ setEditingTipIndex(null);
1657
+ }
1658
+ if (e.key === 'Escape') setEditingTipIndex(null);
1659
+ }}
1660
+ placeholder="AI instruction tip..."
1661
+ 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"
1662
+ autoFocus
1663
+ onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
1664
+ />
1665
+ <button
1666
+ onClick={() => {
1667
+ const updated = [...quickLabelsState];
1668
+ updated[index] = { ...label, tip: editingTipValue || undefined };
1669
+ setQuickLabelsState(updated);
1670
+ saveQuickLabels(updated);
1671
+ setEditingTipIndex(null);
1672
+ }}
1673
+ className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
1674
+ title="Save tip"
1675
+ >
1676
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
1677
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
1678
+ </svg>
1679
+ </button>
1680
+ </div>
1681
+ )}
1682
+ </div>
1683
+ );
1684
+ })}
1685
+ </div>
1686
+
1687
+ {quickLabelsState.length < 12 && (
1688
+ <button
1689
+ onClick={() => {
1690
+ const newLabel: QuickLabel = {
1691
+ id: `custom-${Date.now()}`,
1692
+ emoji: '📌',
1693
+ text: 'New label',
1694
+ color: 'blue',
1695
+ };
1696
+ const updated = [...quickLabelsState, newLabel];
1697
+ setQuickLabelsState(updated);
1698
+ saveQuickLabels(updated);
1699
+ }}
1700
+ className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
1701
+ >
1702
+ + Add label
1703
+ </button>
1704
+ )}
1705
+
1706
+ <div className="text-[10px] text-muted-foreground/70">
1707
+ Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 when the annotation toolbar is visible to apply a label instantly.
1708
+ </div>
1709
+ </>
1710
+ )}
1711
+
1712
+ {/* === SHORTCUTS TAB === */}
1713
+ {activeTab === 'shortcuts' && (
1714
+ <KeyboardShortcuts mode={mode} />
1715
+ )}
1716
+
1717
+ {/* === COMMENTS TAB === */}
1718
+ {activeTab === 'comments' && (
1719
+ <CommentsTab />
1720
+ )}
1721
+
1722
+ {/* === AI TAB === */}
1723
+ {activeTab === 'ai' && (
1724
+ <AISettingsTab
1725
+ providers={aiProviders}
1726
+ selectedProviderId={aiProvider}
1727
+ origin={origin}
1728
+ onProviderChange={setAiProvider}
1729
+ />
1730
+ )}
1731
+
1732
+ {/* === FILES TAB === */}
1733
+ {activeTab === 'files' && (
1734
+ <>
1735
+ <div className="flex items-center justify-between">
1736
+ <div>
1737
+ <div className="text-sm font-medium">File Browser</div>
1738
+ <div className="text-xs text-muted-foreground">
1739
+ Your project files are shown automatically. Add extra directories below.
1740
+ </div>
1741
+ </div>
1742
+ <button
1743
+ role="switch"
1744
+ aria-checked={fileBrowserSettings.enabled}
1745
+ onClick={() => handleFileBrowserChange({ enabled: !fileBrowserSettings.enabled })}
1746
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1747
+ fileBrowserSettings.enabled ? 'bg-primary' : 'bg-muted'
1748
+ }`}
1749
+ >
1750
+ <span
1751
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1752
+ fileBrowserSettings.enabled ? 'translate-x-6' : 'translate-x-1'
1753
+ }`}
1754
+ />
1755
+ </button>
1756
+ </div>
1757
+
1758
+ {fileBrowserSettings.enabled && (
1759
+ <>
1760
+ <div className="border-t border-border" />
1761
+
1762
+ {/* Directory list */}
1763
+ {fileBrowserSettings.directories.length > 0 && (
1764
+ <div className="space-y-1">
1765
+ <label className="text-xs text-muted-foreground">Directories</label>
1766
+ {fileBrowserSettings.directories.map((dir) => (
1767
+ <div key={dir} className="flex items-center gap-2 group">
1768
+ <div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate" title={dir}>
1769
+ {dir}
1770
+ </div>
1771
+ <button
1772
+ onClick={() => handleFileBrowserChange({
1773
+ directories: fileBrowserSettings.directories.filter((d) => d !== dir),
1774
+ })}
1775
+ className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
1776
+ title="Remove directory"
1777
+ >
1778
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1779
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1780
+ </svg>
1781
+ </button>
1782
+ </div>
1783
+ ))}
1784
+ </div>
1785
+ )}
1786
+
1787
+ {/* Add directory */}
1788
+ <div className="space-y-1.5">
1789
+ <label className="text-xs text-muted-foreground">Add Directory</label>
1790
+ <div className="flex gap-2">
1791
+ <input
1792
+ type="text"
1793
+ value={newDirPath}
1794
+ onChange={(e) => setNewDirPath(e.target.value)}
1795
+ onKeyDown={(e) => {
1796
+ if (e.key === 'Enter') addDirectory();
1797
+ }}
1798
+ placeholder="/path/to/directory"
1799
+ className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1800
+ />
1801
+ <button
1802
+ onClick={addDirectory}
1803
+ disabled={!newDirPath.trim()}
1804
+ className="px-3 py-2 bg-primary text-primary-foreground rounded-lg text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
1805
+ >
1806
+ Add
1807
+ </button>
1808
+ </div>
1809
+ <div className="text-[10px] text-muted-foreground/70">
1810
+ Add directories outside your project that contain markdown files.
1811
+ </div>
1812
+ </div>
1813
+ </>
1814
+ )}
1815
+ </>
1816
+ )}
1817
+
1818
+ {/* === HOOKS TAB === */}
1819
+ {activeTab === 'hooks' && <HooksTab />}
1820
+
1821
+ {/* === OBSIDIAN TAB === */}
1822
+ {activeTab === 'obsidian' && (
1823
+ <>
1824
+ <div className="flex items-center justify-between">
1825
+ <div>
1826
+ <div className="text-sm font-medium">Obsidian Integration</div>
1827
+ <div className="text-xs text-muted-foreground">
1828
+ Auto-save approved plans to your vault
1829
+ </div>
1830
+ </div>
1831
+ <button
1832
+ role="switch"
1833
+ aria-checked={obsidian.enabled}
1834
+ onClick={() => handleObsidianChange({ enabled: !obsidian.enabled })}
1835
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
1836
+ obsidian.enabled ? 'bg-primary' : 'bg-muted'
1837
+ }`}
1838
+ >
1839
+ <span
1840
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1841
+ obsidian.enabled ? 'translate-x-6' : 'translate-x-1'
1842
+ }`}
1843
+ />
1844
+ </button>
1845
+ </div>
1846
+
1847
+ {obsidian.enabled && (
1848
+ <>
1849
+ <div className="border-t border-border" />
1850
+
1851
+ <div className="space-y-3">
1852
+ <div className="flex gap-3">
1853
+ <div className="flex-1 space-y-1.5">
1854
+ <label className="text-xs text-muted-foreground">Vault</label>
1855
+ {vaultsLoading ? (
1856
+ <div className="w-full px-3 py-2 bg-muted rounded-lg text-xs text-muted-foreground">
1857
+ Detecting...
1858
+ </div>
1859
+ ) : detectedVaults.length > 0 ? (
1860
+ <>
1861
+ <select
1862
+ value={obsidian.vaultPath}
1863
+ onChange={(e) => handleObsidianChange({ vaultPath: e.target.value })}
1864
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1865
+ >
1866
+ {detectedVaults.map((vault) => (
1867
+ <option key={vault} value={vault}>
1868
+ {vault.split('/').pop() || vault}
1869
+ </option>
1870
+ ))}
1871
+ <option value={CUSTOM_PATH_SENTINEL}>Custom path...</option>
1872
+ </select>
1873
+ {obsidian.vaultPath === CUSTOM_PATH_SENTINEL && (
1874
+ <input
1875
+ type="text"
1876
+ value={obsidian.customPath || ''}
1877
+ onChange={(e) => handleObsidianChange({ customPath: e.target.value })}
1878
+ placeholder="/path/to/vault"
1879
+ className="w-full mt-2 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1880
+ />
1881
+ )}
1882
+ </>
1883
+ ) : (
1884
+ <input
1885
+ type="text"
1886
+ value={obsidian.vaultPath}
1887
+ onChange={(e) => handleObsidianChange({ vaultPath: e.target.value })}
1888
+ placeholder="/path/to/vault"
1889
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1890
+ />
1891
+ )}
1892
+ </div>
1893
+
1894
+ <div className="w-44 space-y-1.5">
1895
+ <label className="text-xs text-muted-foreground">Folder</label>
1896
+ <input
1897
+ type="text"
1898
+ value={obsidian.folder}
1899
+ onChange={(e) => handleObsidianChange({ folder: e.target.value })}
1900
+ placeholder="plannotator"
1901
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1902
+ />
1903
+ </div>
1904
+ </div>
1905
+
1906
+ <div className="space-y-1.5">
1907
+ <label className="text-xs text-muted-foreground">Filename Format</label>
1908
+ <input
1909
+ type="text"
1910
+ value={obsidian.filenameFormat || ''}
1911
+ onChange={(e) => handleObsidianChange({ filenameFormat: e.target.value || undefined })}
1912
+ placeholder={DEFAULT_FILENAME_FORMAT}
1913
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
1914
+ />
1915
+ <div className="text-[10px] text-muted-foreground/70">
1916
+ Variables: <code className="text-[10px]">{'{title}'}</code> <code className="text-[10px]">{'{YYYY}'}</code> <code className="text-[10px]">{'{MM}'}</code> <code className="text-[10px]">{'{DD}'}</code> <code className="text-[10px]">{'{Mon}'}</code> <code className="text-[10px]">{'{D}'}</code> <code className="text-[10px]">{'{HH}'}</code> <code className="text-[10px]">{'{h}'}</code> <code className="text-[10px]">{'{hh}'}</code> <code className="text-[10px]">{'{mm}'}</code> <code className="text-[10px]">{'{ss}'}</code> <code className="text-[10px]">{'{ampm}'}</code>
1917
+ </div>
1918
+ <div className="text-[10px] text-muted-foreground/70">
1919
+ Preview: {(() => {
1920
+ const fmt = obsidian.filenameFormat?.trim() || DEFAULT_FILENAME_FORMAT;
1921
+ const now = new Date();
1922
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1923
+ const h24 = now.getHours(); const h12 = h24 % 12 || 12;
1924
+ const vars: Record<string, string> = {
1925
+ title: 'My Plan Title', YYYY: String(now.getFullYear()),
1926
+ MM: String(now.getMonth()+1).padStart(2,'0'), DD: String(now.getDate()).padStart(2,'0'),
1927
+ Mon: months[now.getMonth()], D: String(now.getDate()),
1928
+ HH: String(h24).padStart(2,'0'), h: String(h12), hh: String(h12).padStart(2,'0'),
1929
+ mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'),
1930
+ ampm: h24 >= 12 ? 'pm' : 'am',
1931
+ };
1932
+ let preview = fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md';
1933
+ if (obsidian.filenameSeparator === 'dash') preview = preview.replace(/ /g, '-');
1934
+ else if (obsidian.filenameSeparator === 'underscore') preview = preview.replace(/ /g, '_');
1935
+ return preview;
1936
+ })()}
1937
+ </div>
1938
+ </div>
1939
+
1940
+ <div className="space-y-1.5">
1941
+ <label className="text-xs text-muted-foreground">Filename Separator</label>
1942
+ <select
1943
+ value={obsidian.filenameSeparator || 'space'}
1944
+ onChange={(e) => handleObsidianChange({ filenameSeparator: e.target.value as 'space' | 'dash' | 'underscore' })}
1945
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
1946
+ >
1947
+ <option value="space">Spaces (default)</option>
1948
+ <option value="dash">Dashes (-)</option>
1949
+ <option value="underscore">Underscores (_)</option>
1950
+ </select>
1951
+ <div className="text-[10px] text-muted-foreground/70">
1952
+ Replaces spaces in the generated filename. Useful when working with CLI tools in your vault.
1953
+ </div>
1954
+ </div>
1955
+
1956
+ <div className="text-[10px] text-muted-foreground/70">
1957
+ Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
1958
+ ? (obsidian.customPath || '...')
1959
+ : (obsidian.vaultPath || '...')}/{obsidian.folder || 'plannotator'}/
1960
+ </div>
1961
+
1962
+ <div className="space-y-1.5">
1963
+ <label className="text-xs text-muted-foreground">Frontmatter (auto-generated)</label>
1964
+ <pre className="px-3 py-2 bg-muted/50 rounded-lg text-[10px] font-mono text-muted-foreground overflow-x-auto">
1965
+ {`---
1966
+ created: ${new Date().toISOString().slice(0, 19)}Z
1967
+ source: plannotator
1968
+ tags: [plan, ...]
1969
+ ---`}
1970
+ </pre>
1971
+ </div>
1972
+
1973
+ <div className="border-t border-border/30" />
1974
+
1975
+ <div className="flex items-center justify-between">
1976
+ <div>
1977
+ <div className="text-xs font-medium">Auto-save on Plan Arrival</div>
1978
+ <div className="text-[10px] text-muted-foreground">
1979
+ Automatically save to Obsidian when a plan loads, before you approve or deny
1980
+ </div>
1981
+ </div>
1982
+ <button
1983
+ role="switch"
1984
+ aria-checked={obsidian.autoSave}
1985
+ onClick={() => handleObsidianChange({ autoSave: !obsidian.autoSave })}
1986
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
1987
+ obsidian.autoSave ? 'bg-primary' : 'bg-muted'
1988
+ }`}
1989
+ >
1990
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
1991
+ obsidian.autoSave ? 'translate-x-6' : 'translate-x-1'
1992
+ }`} />
1993
+ </button>
1994
+ </div>
1995
+
1996
+ <div className="flex items-center justify-between">
1997
+ <div>
1998
+ <div className="text-xs font-medium">Vault Browser</div>
1999
+ <div className="text-[10px] text-muted-foreground">
2000
+ Browse and annotate vault files from the sidebar
2001
+ </div>
2002
+ </div>
2003
+ <button
2004
+ role="switch"
2005
+ aria-checked={obsidian.vaultBrowserEnabled}
2006
+ onClick={() => handleObsidianChange({ vaultBrowserEnabled: !obsidian.vaultBrowserEnabled })}
2007
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
2008
+ obsidian.vaultBrowserEnabled ? 'bg-primary' : 'bg-muted'
2009
+ }`}
2010
+ >
2011
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
2012
+ obsidian.vaultBrowserEnabled ? 'translate-x-6' : 'translate-x-1'
2013
+ }`} />
2014
+ </button>
2015
+ </div>
2016
+ </div>
2017
+ </>
2018
+ )}
2019
+ </>
2020
+ )}
2021
+
2022
+ {/* === BEAR TAB === */}
2023
+ {activeTab === 'bear' && (
2024
+ <>
2025
+ <div className="flex items-center justify-between">
2026
+ <div>
2027
+ <div className="text-sm font-medium">Bear Notes</div>
2028
+ <div className="text-xs text-muted-foreground">
2029
+ Auto-save approved plans to Bear
2030
+ </div>
2031
+ </div>
2032
+ <button
2033
+ role="switch"
2034
+ aria-checked={bear.enabled}
2035
+ onClick={() => handleBearChange({ enabled: !bear.enabled })}
2036
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
2037
+ bear.enabled ? 'bg-primary' : 'bg-muted'
2038
+ }`}
2039
+ >
2040
+ <span
2041
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
2042
+ bear.enabled ? 'translate-x-6' : 'translate-x-1'
2043
+ }`}
2044
+ />
2045
+ </button>
2046
+ </div>
2047
+ {bear.enabled && (
2048
+ <div className="mt-3 space-y-3">
2049
+ <div className="space-y-1.5 pl-0.5">
2050
+ <label className="text-xs text-muted-foreground">Custom Tags</label>
2051
+ <input
2052
+ type="text"
2053
+ value={bear.customTags}
2054
+ onChange={(e) => handleBearChange({ customTags: e.target.value })}
2055
+ onBlur={(e) => handleBearChange({ customTags: normalizeTags(e.target.value) })}
2056
+ placeholder="plan, work"
2057
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
2058
+ />
2059
+ <div className="text-[10px] text-muted-foreground">
2060
+ Comma-separated, kebab-case. Leave empty for auto-generated tags.
2061
+ </div>
2062
+ </div>
2063
+ <div className="space-y-1.5 pl-0.5">
2064
+ <label className="text-xs text-muted-foreground">Tag Position</label>
2065
+ <select
2066
+ value={bear.tagPosition}
2067
+ onChange={(e) => handleBearChange({ tagPosition: e.target.value as 'prepend' | 'append' })}
2068
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
2069
+ >
2070
+ <option value="append">Append (end of note)</option>
2071
+ <option value="prepend">Prepend (after title)</option>
2072
+ </select>
2073
+ </div>
2074
+
2075
+ <div className="border-t border-border/30" />
2076
+
2077
+ <div className="flex items-center justify-between">
2078
+ <div>
2079
+ <div className="text-xs font-medium">Auto-save on Plan Arrival</div>
2080
+ <div className="text-[10px] text-muted-foreground">
2081
+ Automatically save to Bear when a plan loads, before you approve or deny
2082
+ </div>
2083
+ </div>
2084
+ <button
2085
+ role="switch"
2086
+ aria-checked={bear.autoSave}
2087
+ onClick={() => handleBearChange({ autoSave: !bear.autoSave })}
2088
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
2089
+ bear.autoSave ? 'bg-primary' : 'bg-muted'
2090
+ }`}
2091
+ >
2092
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
2093
+ bear.autoSave ? 'translate-x-6' : 'translate-x-1'
2094
+ }`} />
2095
+ </button>
2096
+ </div>
2097
+ </div>
2098
+ )}
2099
+ </>
2100
+ )}
2101
+
2102
+ {/* === OCTARINE TAB === */}
2103
+ {activeTab === 'octarine' && (
2104
+ <>
2105
+ <div className="flex items-center justify-between">
2106
+ <div>
2107
+ <div className="text-sm font-medium">Octarine</div>
2108
+ <div className="text-xs text-muted-foreground">
2109
+ Auto-save approved plans to Octarine
2110
+ </div>
2111
+ </div>
2112
+ <button
2113
+ role="switch"
2114
+ aria-checked={octarine.enabled}
2115
+ onClick={() => handleOctarineChange({ enabled: !octarine.enabled })}
2116
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
2117
+ octarine.enabled ? 'bg-primary' : 'bg-muted'
2118
+ }`}
2119
+ >
2120
+ <span
2121
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
2122
+ octarine.enabled ? 'translate-x-6' : 'translate-x-1'
2123
+ }`}
2124
+ />
2125
+ </button>
2126
+ </div>
2127
+ {octarine.enabled && (
2128
+ <div className="mt-3 space-y-3">
2129
+ <div className="space-y-1.5 pl-0.5">
2130
+ <label className="text-xs text-muted-foreground">Workspace Name</label>
2131
+ <input
2132
+ type="text"
2133
+ value={octarine.workspace}
2134
+ onChange={(e) => handleOctarineChange({ workspace: e.target.value })}
2135
+ placeholder="My Workspace"
2136
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
2137
+ />
2138
+ <div className="text-[10px] text-muted-foreground">
2139
+ The Octarine workspace name to save plans into.
2140
+ </div>
2141
+ </div>
2142
+ <div className="space-y-1.5 pl-0.5">
2143
+ <label className="text-xs text-muted-foreground">Folder</label>
2144
+ <input
2145
+ type="text"
2146
+ value={octarine.folder}
2147
+ onChange={(e) => handleOctarineChange({ folder: e.target.value })}
2148
+ placeholder="plannotator"
2149
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
2150
+ />
2151
+ <div className="text-[10px] text-muted-foreground">
2152
+ Subfolder within the workspace for saved plans.
2153
+ </div>
2154
+ </div>
2155
+
2156
+ <div className="text-[10px] text-muted-foreground/70">
2157
+ Plans saved to: {octarine.workspace || '...'} / {octarine.folder || 'plannotator'}/
2158
+ </div>
2159
+
2160
+ <div className="border-t border-border/30" />
2161
+
2162
+ <div className="flex items-center justify-between">
2163
+ <div>
2164
+ <div className="text-xs font-medium">Auto-save on Plan Arrival</div>
2165
+ <div className="text-[10px] text-muted-foreground">
2166
+ Automatically save to Octarine when a plan loads, before you approve or deny
2167
+ </div>
2168
+ </div>
2169
+ <button
2170
+ role="switch"
2171
+ aria-checked={octarine.autoSave}
2172
+ onClick={() => handleOctarineChange({ autoSave: !octarine.autoSave })}
2173
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
2174
+ octarine.autoSave ? 'bg-primary' : 'bg-muted'
2175
+ }`}
2176
+ >
2177
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
2178
+ octarine.autoSave ? 'translate-x-6' : 'translate-x-1'
2179
+ }`} />
2180
+ </button>
2181
+ </div>
2182
+ </div>
2183
+ )}
2184
+ </>
2185
+ )}
2186
+
2187
+ </div>
2188
+ </OverlayScrollArea>
2189
+ </div>
2190
+ </div>
2191
+ </div>,
2192
+ document.body
2193
+ )}
2194
+
2195
+ {themePreview && createPortal(
2196
+ <div className="fixed inset-0 z-[100] flex flex-col pointer-events-none">
2197
+ <div className="flex-1" />
2198
+ <div
2199
+ className="pointer-events-auto w-full bg-card border-t-2 border-primary/30 shadow-[0_-4px_20px_rgba(0,0,0,0.4)] flex flex-col max-h-[35vh] overflow-hidden"
2200
+ onClick={e => e.stopPropagation()}
2201
+ >
2202
+ <div className="flex items-center justify-between px-4 py-2.5 border-b border-border flex-shrink-0">
2203
+ <span className="text-xs font-medium text-muted-foreground">Theme Preview</span>
2204
+ <button
2205
+ onClick={() => { setThemePreview(false); setShowDialog(true); }}
2206
+ className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
2207
+ >
2208
+ Done
2209
+ </button>
2210
+ </div>
2211
+ <div className="p-3 overflow-y-auto flex-1 min-h-0">
2212
+ <ThemeTab compact />
2213
+ </div>
2214
+ </div>
2215
+ </div>,
2216
+ document.body
2217
+ )}
2218
+ </>
2219
+ );
2220
+ };