@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,579 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getItem, setItem } from '../utils/storage';
3
+
4
+ const COOKIE_KEY = 'plannotator.agents';
5
+
6
+ // Multiple live instances of this hook can be mounted at once (e.g. the
7
+ // Settings AgentsTab and the Guided Review empty-state launch panel are both
8
+ // mounted simultaneously). Each instance held fully independent state and
9
+ // wrote the WHOLE cookie blob on every change, so the last writer clobbered
10
+ // any change the other instance made in the meantime (e.g. picking a guide
11
+ // model in one tab silently reverted a review-engine pick made in the other).
12
+ // `settingsListeners` lets every write broadcast its resulting state to every
13
+ // OTHER mounted instance so they stay in sync without a shared store. Listeners
14
+ // must ONLY call setState — never re-write the cookie — or a broadcast loop
15
+ // would clobber writes exactly like before.
16
+ const settingsListeners = new Set<(s: AgentSettingsState) => void>();
17
+
18
+ export const DEFAULT_CLAUDE_MODEL = 'claude-opus-4-7';
19
+ export const DEFAULT_CLAUDE_EFFORT = 'high';
20
+ // gpt-5.3-codex is deprecated (ChatGPT-account Codex rejects it outright) —
21
+ // default to the current flagship everywhere.
22
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.5';
23
+ export const DEFAULT_CODEX_REASONING = 'high';
24
+ export const DEFAULT_CODEX_FAST = false;
25
+ export const DEFAULT_TOUR_CLAUDE_MODEL = 'sonnet';
26
+ export const DEFAULT_TOUR_CLAUDE_EFFORT = 'medium';
27
+ export const DEFAULT_TOUR_CODEX_MODEL = 'gpt-5.5';
28
+ export const DEFAULT_TOUR_CODEX_REASONING = 'medium';
29
+ export const DEFAULT_TOUR_CODEX_FAST = false;
30
+ export const DEFAULT_GUIDE_CLAUDE_MODEL = 'sonnet';
31
+ // Guide defaults run LOWER effort than tour/review on purpose: a guide is an
32
+ // orientation doc the reviewer is actively waiting on, and newer models at
33
+ // low effort chapter a diff well. Guide-scoped only — tour/review keep medium.
34
+ export const DEFAULT_GUIDE_CLAUDE_EFFORT = 'low';
35
+ export const DEFAULT_GUIDE_CODEX_MODEL = 'gpt-5.5';
36
+ export const DEFAULT_GUIDE_CODEX_REASONING = 'low';
37
+ // No DEFAULT_GUIDE_CODEX_FAST: fast mode is deliberately not offered for
38
+ // guide (product decision — see AgentsTab's guide codex config block), so
39
+ // there is no getter/setter to default. patchCodex below still needs SOME
40
+ // `fast` default to satisfy CodexSection's per-model shape when it seeds a
41
+ // new model entry; DEFAULT_CODEX_FAST (false, same value) covers that without
42
+ // implying a guide-specific fast toggle exists.
43
+ // `auto` is Cursor's own default model id (from `agent models`); lowercase so it
44
+ // matches the discovered catalog and the buildCursorCommand omit-`--model` check.
45
+ export const DEFAULT_CURSOR_MODEL = 'auto';
46
+
47
+ // OpenCode has no `auto` pseudo-model; empty string means "use OpenCode's
48
+ // configured default" and buildOpencodeCommand omits `--model` for it.
49
+ export const DEFAULT_OPENCODE_MODEL = '';
50
+
51
+ // Pi has no `auto` pseudo-model either; empty string means "use Pi's own
52
+ // default" and buildArgv omits `--model` for it — same convention as OpenCode.
53
+ export const DEFAULT_PI_MODEL = '';
54
+ // Pi's unified reasoning knob (`--thinking`); 'medium' matches Pi's own default.
55
+ export const DEFAULT_PI_THINKING = 'medium';
56
+
57
+ // Copilot has no `auto` pseudo-model in our picker; empty string means "let
58
+ // Copilot pick" and copilotBuildArgv omits `--model` for it — same convention
59
+ // as OpenCode/Pi.
60
+ export const DEFAULT_COPILOT_MODEL = '';
61
+
62
+ // Guide-scoped marker-engine defaults — same isolation rationale as
63
+ // DEFAULT_GUIDE_CLAUDE_EFFORT above (a guide's Cursor/OpenCode/Pi model must
64
+ // not silently change the next Cursor/OpenCode/Pi code review, and vice
65
+ // versa). These literal values happen to match the shared defaults above —
66
+ // they are NOT derived/seeded from them, just each engine's own natural
67
+ // default picked independently for the guide surface.
68
+ export const DEFAULT_GUIDE_CURSOR_MODEL = 'auto';
69
+ export const DEFAULT_GUIDE_OPENCODE_MODEL = '';
70
+ export const DEFAULT_GUIDE_PI_MODEL = '';
71
+ export const DEFAULT_GUIDE_PI_THINKING = 'medium';
72
+ export const DEFAULT_GUIDE_COPILOT_MODEL = '';
73
+
74
+ interface ClaudeSection {
75
+ model: string;
76
+ perModel: Record<string, { effort: string }>;
77
+ }
78
+
79
+ interface CodexSection {
80
+ model: string;
81
+ perModel: Record<string, { reasoning: string; fast: boolean }>;
82
+ }
83
+
84
+ // Cursor/OpenCode have no per-model sub-settings (no effort/reasoning), so a
85
+ // flat { model } section is enough — deliberately simpler than Claude/Codex.
86
+ interface CursorSection {
87
+ model: string; // 'auto' or a discovered model id
88
+ }
89
+
90
+ interface OpencodeSection {
91
+ model: string; // '' (default) or a discovered provider/model id
92
+ }
93
+
94
+ // Pi: flat model plus its single global reasoning knob (`--thinking`), which
95
+ // applies to whatever model is selected — unlike Claude/Codex there is no
96
+ // per-model effort map.
97
+ interface PiSection {
98
+ model: string; // '' (default) or a discovered model id
99
+ thinking: string; // off | minimal | low | medium | high | xhigh
100
+ }
101
+
102
+ // Copilot: flat model only, like Cursor/OpenCode (its --effort knob is
103
+ // deliberately not surfaced yet — the CLI default is sensible).
104
+ interface CopilotSection {
105
+ model: string; // '' (default) or a model id from `copilot help config`
106
+ }
107
+
108
+ export type AgentMode = 'review' | 'tour' | 'guide';
109
+ export type AgentEngine = 'claude' | 'codex';
110
+ // Review-only engine union. Tour stays on the narrow AgentEngine so its
111
+ // exhaustive Record<AgentEngine, ...> maps remain valid without change.
112
+ export type ReviewEngine = AgentEngine | 'cursor' | 'opencode' | 'pi' | 'copilot';
113
+
114
+ interface AgentSettingsState {
115
+ selectedMode?: AgentMode;
116
+ reviewEngine: ReviewEngine;
117
+ // Selected review profile is tracked per review engine so each agent can have
118
+ // its own default (e.g. Claude runs one review, Cursor another) — mirrors how
119
+ // `model` is per-engine. The current value is reviewProfileByEngine[reviewEngine].
120
+ reviewProfileByEngine: Record<ReviewEngine, string>;
121
+ tourEngine: AgentEngine;
122
+ // Guide runs on the wide union: marker engines (cursor/opencode) generate
123
+ // guides via the marker-block JSON contract, same as marker review jobs.
124
+ guideEngine: ReviewEngine;
125
+ claude: ClaudeSection;
126
+ codex: CodexSection;
127
+ cursor: CursorSection;
128
+ opencode: OpencodeSection;
129
+ pi: PiSection;
130
+ copilot: CopilotSection;
131
+ tourClaude: ClaudeSection;
132
+ tourCodex: CodexSection;
133
+ guideClaude: ClaudeSection;
134
+ guideCodex: CodexSection;
135
+ // Guide-scoped marker-engine settings — kept separate from cursor/opencode/pi
136
+ // above (same isolation as guideClaude/guideCodex vs claude/codex) so tuning
137
+ // a guide's Cursor/OpenCode/Pi model doesn't silently change the next
138
+ // Cursor/OpenCode/Pi code review.
139
+ guideCursor: CursorSection;
140
+ guideOpencode: OpencodeSection;
141
+ guidePi: PiSection;
142
+ guideCopilot: CopilotSection;
143
+ }
144
+
145
+ const BUILTIN_DEFAULT_PROFILE = 'builtin:default';
146
+ const REVIEW_ENGINES: ReviewEngine[] = ['claude', 'codex', 'cursor', 'opencode', 'pi', 'copilot'];
147
+
148
+ const initialState: AgentSettingsState = {
149
+ selectedMode: 'review',
150
+ reviewEngine: 'claude',
151
+ reviewProfileByEngine: {
152
+ claude: BUILTIN_DEFAULT_PROFILE,
153
+ codex: BUILTIN_DEFAULT_PROFILE,
154
+ cursor: BUILTIN_DEFAULT_PROFILE,
155
+ opencode: BUILTIN_DEFAULT_PROFILE,
156
+ pi: BUILTIN_DEFAULT_PROFILE,
157
+ copilot: BUILTIN_DEFAULT_PROFILE,
158
+ },
159
+ tourEngine: 'claude',
160
+ guideEngine: 'claude',
161
+ claude: { model: DEFAULT_CLAUDE_MODEL, perModel: {} },
162
+ codex: { model: DEFAULT_CODEX_MODEL, perModel: {} },
163
+ cursor: { model: DEFAULT_CURSOR_MODEL },
164
+ opencode: { model: DEFAULT_OPENCODE_MODEL },
165
+ pi: { model: DEFAULT_PI_MODEL, thinking: DEFAULT_PI_THINKING },
166
+ copilot: { model: DEFAULT_COPILOT_MODEL },
167
+ tourClaude: { model: DEFAULT_TOUR_CLAUDE_MODEL, perModel: {} },
168
+ tourCodex: { model: DEFAULT_TOUR_CODEX_MODEL, perModel: {} },
169
+ guideClaude: { model: DEFAULT_GUIDE_CLAUDE_MODEL, perModel: {} },
170
+ guideCodex: { model: DEFAULT_GUIDE_CODEX_MODEL, perModel: {} },
171
+ guideCursor: { model: DEFAULT_GUIDE_CURSOR_MODEL },
172
+ guideOpencode: { model: DEFAULT_GUIDE_OPENCODE_MODEL },
173
+ guidePi: { model: DEFAULT_GUIDE_PI_MODEL, thinking: DEFAULT_GUIDE_PI_THINKING },
174
+ guideCopilot: { model: DEFAULT_GUIDE_COPILOT_MODEL },
175
+ };
176
+
177
+ // One-shot migration: drop any cached "none" codex reasoning entries. The
178
+ // dropdown no longer offers "None" (codex-rs rejects it as a config value);
179
+ // fall back to the default instead of shipping an invalid flag.
180
+ export function sanitizeCodexPerModel(
181
+ perModel: Record<string, { reasoning: string; fast: boolean }> | undefined,
182
+ ): Record<string, { reasoning: string; fast: boolean }> {
183
+ if (!perModel) return {};
184
+ const out: Record<string, { reasoning: string; fast: boolean }> = {};
185
+ for (const [model, entry] of Object.entries(perModel)) {
186
+ if (!entry || typeof entry !== 'object') continue;
187
+ if (entry.reasoning === 'none') {
188
+ if (entry.fast) out[model] = { reasoning: DEFAULT_CODEX_REASONING, fast: true };
189
+ continue;
190
+ }
191
+ out[model] = entry;
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // One-shot migration: gpt-5.3-codex is deprecated (ChatGPT-account Codex
197
+ // rejects it with a 400), so a saved pick of it silently becomes the current
198
+ // default rather than shipping a launch that can never succeed.
199
+ function migrateCodexModel(value: unknown, fallback: string): string {
200
+ if (typeof value !== 'string' || value === 'gpt-5.3-codex') return fallback;
201
+ return value;
202
+ }
203
+
204
+ function parseEngine(value: unknown): AgentEngine {
205
+ return value === 'codex' ? 'codex' : 'claude';
206
+ }
207
+
208
+ function parseReviewEngine(value: unknown): ReviewEngine {
209
+ if (value === 'cursor') return 'cursor';
210
+ if (value === 'opencode') return 'opencode';
211
+ if (value === 'pi') return 'pi';
212
+ if (value === 'copilot') return 'copilot';
213
+ return parseEngine(value);
214
+ }
215
+
216
+ function parseMode(value: unknown): AgentMode | undefined {
217
+ if (value === 'review' || value === 'tour' || value === 'guide') return value;
218
+ return undefined;
219
+ }
220
+
221
+ // Parse the per-engine review map, migrating the old flat global `reviewProfileId`:
222
+ // seed every engine with it so an existing pick isn't lost; engines diverge from there.
223
+ export function parseReviewProfileByEngine(parsed: {
224
+ reviewProfileByEngine?: unknown;
225
+ reviewProfileId?: unknown;
226
+ }): Record<ReviewEngine, string> {
227
+ const byEngine = parsed.reviewProfileByEngine as Record<string, unknown> | undefined;
228
+ const legacy = typeof parsed.reviewProfileId === 'string' ? parsed.reviewProfileId : BUILTIN_DEFAULT_PROFILE;
229
+ const out = {} as Record<ReviewEngine, string>;
230
+ for (const engine of REVIEW_ENGINES) {
231
+ out[engine] = typeof byEngine?.[engine] === 'string' ? (byEngine[engine] as string) : legacy;
232
+ }
233
+ return out;
234
+ }
235
+
236
+ function readCookie(): AgentSettingsState {
237
+ const raw = getItem(COOKIE_KEY);
238
+ if (!raw) return initialState;
239
+ try {
240
+ const parsed = JSON.parse(raw);
241
+ return {
242
+ selectedMode: parseMode(parsed.selectedMode) ?? initialState.selectedMode,
243
+ reviewEngine: parseReviewEngine(parsed.reviewEngine),
244
+ reviewProfileByEngine: parseReviewProfileByEngine(parsed),
245
+ tourEngine: parseEngine(parsed.tourEngine),
246
+ guideEngine: parseReviewEngine(parsed.guideEngine),
247
+ claude: {
248
+ model: typeof parsed.claude?.model === 'string' ? parsed.claude.model : DEFAULT_CLAUDE_MODEL,
249
+ perModel: parsed.claude?.perModel ?? {},
250
+ },
251
+ codex: {
252
+ model: migrateCodexModel(parsed.codex?.model, DEFAULT_CODEX_MODEL),
253
+ perModel: sanitizeCodexPerModel(parsed.codex?.perModel),
254
+ },
255
+ cursor: {
256
+ model: typeof parsed.cursor?.model === 'string' ? parsed.cursor.model : DEFAULT_CURSOR_MODEL,
257
+ },
258
+ opencode: {
259
+ model: typeof parsed.opencode?.model === 'string' ? parsed.opencode.model : DEFAULT_OPENCODE_MODEL,
260
+ },
261
+ pi: {
262
+ model: typeof parsed.pi?.model === 'string' ? parsed.pi.model : DEFAULT_PI_MODEL,
263
+ thinking: typeof parsed.pi?.thinking === 'string' ? parsed.pi.thinking : DEFAULT_PI_THINKING,
264
+ },
265
+ copilot: {
266
+ model: typeof parsed.copilot?.model === 'string' ? parsed.copilot.model : DEFAULT_COPILOT_MODEL,
267
+ },
268
+ tourClaude: {
269
+ model: typeof parsed.tourClaude?.model === 'string' ? parsed.tourClaude.model : DEFAULT_TOUR_CLAUDE_MODEL,
270
+ perModel: parsed.tourClaude?.perModel ?? {},
271
+ },
272
+ tourCodex: {
273
+ model: migrateCodexModel(parsed.tourCodex?.model, DEFAULT_TOUR_CODEX_MODEL),
274
+ perModel: sanitizeCodexPerModel(parsed.tourCodex?.perModel),
275
+ },
276
+ guideClaude: {
277
+ model: typeof parsed.guideClaude?.model === 'string' ? parsed.guideClaude.model : DEFAULT_GUIDE_CLAUDE_MODEL,
278
+ perModel: parsed.guideClaude?.perModel ?? {},
279
+ },
280
+ guideCodex: {
281
+ model: migrateCodexModel(parsed.guideCodex?.model, DEFAULT_GUIDE_CODEX_MODEL),
282
+ perModel: sanitizeCodexPerModel(parsed.guideCodex?.perModel),
283
+ },
284
+ guideCursor: {
285
+ model: typeof parsed.guideCursor?.model === 'string' ? parsed.guideCursor.model : DEFAULT_GUIDE_CURSOR_MODEL,
286
+ },
287
+ guideOpencode: {
288
+ model: typeof parsed.guideOpencode?.model === 'string' ? parsed.guideOpencode.model : DEFAULT_GUIDE_OPENCODE_MODEL,
289
+ },
290
+ guidePi: {
291
+ model: typeof parsed.guidePi?.model === 'string' ? parsed.guidePi.model : DEFAULT_GUIDE_PI_MODEL,
292
+ thinking: typeof parsed.guidePi?.thinking === 'string' ? parsed.guidePi.thinking : DEFAULT_GUIDE_PI_THINKING,
293
+ },
294
+ guideCopilot: {
295
+ model: typeof parsed.guideCopilot?.model === 'string' ? parsed.guideCopilot.model : DEFAULT_GUIDE_COPILOT_MODEL,
296
+ },
297
+ };
298
+ } catch {
299
+ return initialState;
300
+ }
301
+ }
302
+
303
+ export function useAgentSettings() {
304
+ const [state, setState] = useState<AgentSettingsState>(readCookie);
305
+ // Serialized form of the state this instance knows is already persisted
306
+ // and broadcast. The persist effect compares VALUES against this instead
307
+ // of using a "was the last update remote?" boolean: a boolean conflates
308
+ // "a commit happened" with "the LAST change was remote", so a local edit
309
+ // landing in the same commit window as an incoming broadcast (fast input
310
+ // between paint and effects, or a setter batched with the remote apply)
311
+ // would consume the flag and silently skip its own cookie write and
312
+ // rebroadcast. Value comparison is idempotent: a pure remote apply
313
+ // serializes identically and skips; ANY local delta differs and persists.
314
+ const lastSyncedJsonRef = useRef<string | null>(null);
315
+ // This instance's own listener function, so the broadcast loop below can
316
+ // skip notifying itself.
317
+ const ownListenerRef = useRef<((s: AgentSettingsState) => void) | null>(null);
318
+
319
+ // Register to receive broadcasts from other mounted instances (e.g. the
320
+ // Settings AgentsTab and the Guided Review empty-state launch panel are
321
+ // both mounted at once, each with independent state — without this, the
322
+ // last writer's cookie write would clobber the other's in-flight change).
323
+ useEffect(() => {
324
+ const listener = (next: AgentSettingsState) => {
325
+ lastSyncedJsonRef.current = JSON.stringify(next);
326
+ setState(next);
327
+ };
328
+ ownListenerRef.current = listener;
329
+ settingsListeners.add(listener);
330
+ return () => {
331
+ settingsListeners.delete(listener);
332
+ ownListenerRef.current = null;
333
+ };
334
+ }, []);
335
+
336
+ useEffect(() => {
337
+ const json = JSON.stringify(state);
338
+ if (json === lastSyncedJsonRef.current) return;
339
+ lastSyncedJsonRef.current = json;
340
+ setItem(COOKIE_KEY, json);
341
+ for (const listener of settingsListeners) {
342
+ if (listener !== ownListenerRef.current) listener(state);
343
+ }
344
+ }, [state]);
345
+
346
+ const setSelectedMode = useCallback((mode: AgentMode) => {
347
+ setState((s) => ({ ...s, selectedMode: mode }));
348
+ }, []);
349
+
350
+ const setReviewEngine = useCallback((engine: ReviewEngine) => {
351
+ setState((s) => ({ ...s, reviewEngine: engine }));
352
+ }, []);
353
+
354
+ // Writes the review for the CURRENTLY selected engine, so each engine keeps its own.
355
+ const setReviewProfileId = useCallback((id: string) => {
356
+ setState((s) => ({
357
+ ...s,
358
+ reviewProfileByEngine: { ...s.reviewProfileByEngine, [s.reviewEngine]: id },
359
+ }));
360
+ }, []);
361
+
362
+ const setTourEngine = useCallback((engine: AgentEngine) => {
363
+ setState((s) => ({ ...s, tourEngine: engine }));
364
+ }, []);
365
+
366
+ const setGuideEngine = useCallback((engine: ReviewEngine) => {
367
+ setState((s) => ({ ...s, guideEngine: engine }));
368
+ }, []);
369
+
370
+ const setClaudeModel = useCallback((model: string) => {
371
+ setState((s) => ({ ...s, claude: { ...s.claude, model } }));
372
+ }, []);
373
+
374
+ const patchClaude = useCallback(
375
+ (section: 'claude' | 'tourClaude' | 'guideClaude', patch: Partial<{ effort: string }>) => {
376
+ setState((s) => {
377
+ const cur = s[section];
378
+ const prev = cur.perModel[cur.model] ?? { effort: '' };
379
+ return {
380
+ ...s,
381
+ [section]: {
382
+ ...cur,
383
+ perModel: { ...cur.perModel, [cur.model]: { ...prev, ...patch } },
384
+ },
385
+ };
386
+ });
387
+ },
388
+ [],
389
+ );
390
+
391
+ const setClaudeEffort = useCallback(
392
+ (effort: string) => patchClaude('claude', { effort }),
393
+ [patchClaude],
394
+ );
395
+
396
+ const setCodexModel = useCallback((model: string) => {
397
+ setState((s) => ({ ...s, codex: { ...s.codex, model } }));
398
+ }, []);
399
+
400
+ const setCursorModel = useCallback((model: string) => {
401
+ setState((s) => ({ ...s, cursor: { ...s.cursor, model } }));
402
+ }, []);
403
+
404
+ const setOpencodeModel = useCallback((model: string) => {
405
+ setState((s) => ({ ...s, opencode: { ...s.opencode, model } }));
406
+ }, []);
407
+
408
+ const setPiModel = useCallback((model: string) => {
409
+ setState((s) => ({ ...s, pi: { ...s.pi, model } }));
410
+ }, []);
411
+
412
+ const setPiThinking = useCallback((thinking: string) => {
413
+ setState((s) => ({ ...s, pi: { ...s.pi, thinking } }));
414
+ }, []);
415
+
416
+ const setCopilotModel = useCallback((model: string) => {
417
+ setState((s) => ({ ...s, copilot: { ...s.copilot, model } }));
418
+ }, []);
419
+
420
+ const patchCodex = useCallback(
421
+ (
422
+ section: 'codex' | 'tourCodex' | 'guideCodex',
423
+ patch: Partial<{ reasoning: string; fast: boolean }>,
424
+ defaults: { reasoning: string; fast: boolean },
425
+ ) => {
426
+ setState((s) => {
427
+ const cur = s[section];
428
+ const prev = cur.perModel[cur.model] ?? defaults;
429
+ return {
430
+ ...s,
431
+ [section]: {
432
+ ...cur,
433
+ perModel: { ...cur.perModel, [cur.model]: { ...prev, ...patch } },
434
+ },
435
+ };
436
+ });
437
+ },
438
+ [],
439
+ );
440
+
441
+ const setCodexReasoning = useCallback(
442
+ (reasoning: string) => patchCodex('codex', { reasoning }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
443
+ [patchCodex],
444
+ );
445
+ const setCodexFast = useCallback(
446
+ (fast: boolean) => patchCodex('codex', { fast }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
447
+ [patchCodex],
448
+ );
449
+
450
+ const setTourClaudeModel = useCallback((model: string) => {
451
+ setState((s) => ({ ...s, tourClaude: { ...s.tourClaude, model } }));
452
+ }, []);
453
+
454
+ const setTourClaudeEffort = useCallback(
455
+ (effort: string) => patchClaude('tourClaude', { effort }),
456
+ [patchClaude],
457
+ );
458
+
459
+ const setTourCodexModel = useCallback((model: string) => {
460
+ setState((s) => ({ ...s, tourCodex: { ...s.tourCodex, model } }));
461
+ }, []);
462
+
463
+ const setTourCodexReasoning = useCallback(
464
+ (reasoning: string) => patchCodex('tourCodex', { reasoning }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
465
+ [patchCodex],
466
+ );
467
+ const setTourCodexFast = useCallback(
468
+ (fast: boolean) => patchCodex('tourCodex', { fast }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
469
+ [patchCodex],
470
+ );
471
+
472
+ const setGuideClaudeModel = useCallback((model: string) => {
473
+ setState((s) => ({ ...s, guideClaude: { ...s.guideClaude, model } }));
474
+ }, []);
475
+
476
+ const setGuideClaudeEffort = useCallback(
477
+ (effort: string) => patchClaude('guideClaude', { effort }),
478
+ [patchClaude],
479
+ );
480
+
481
+ const setGuideCodexModel = useCallback((model: string) => {
482
+ setState((s) => ({ ...s, guideCodex: { ...s.guideCodex, model } }));
483
+ }, []);
484
+
485
+ const setGuideCodexReasoning = useCallback(
486
+ (reasoning: string) => patchCodex('guideCodex', { reasoning }, { reasoning: DEFAULT_GUIDE_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
487
+ [patchCodex],
488
+ );
489
+
490
+ const setGuideCursorModel = useCallback((model: string) => {
491
+ setState((s) => ({ ...s, guideCursor: { ...s.guideCursor, model } }));
492
+ }, []);
493
+
494
+ const setGuideOpencodeModel = useCallback((model: string) => {
495
+ setState((s) => ({ ...s, guideOpencode: { ...s.guideOpencode, model } }));
496
+ }, []);
497
+
498
+ const setGuidePiModel = useCallback((model: string) => {
499
+ setState((s) => ({ ...s, guidePi: { ...s.guidePi, model } }));
500
+ }, []);
501
+
502
+ const setGuidePiThinking = useCallback((thinking: string) => {
503
+ setState((s) => ({ ...s, guidePi: { ...s.guidePi, thinking } }));
504
+ }, []);
505
+
506
+ const setGuideCopilotModel = useCallback((model: string) => {
507
+ setState((s) => ({ ...s, guideCopilot: { ...s.guideCopilot, model } }));
508
+ }, []);
509
+
510
+ const claudeEffort = state.claude.perModel[state.claude.model]?.effort ?? DEFAULT_CLAUDE_EFFORT;
511
+ const codexReasoning = state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING;
512
+ const codexFast = state.codex.perModel[state.codex.model]?.fast ?? DEFAULT_CODEX_FAST;
513
+ const tourClaudeEffort = state.tourClaude.perModel[state.tourClaude.model]?.effort ?? DEFAULT_TOUR_CLAUDE_EFFORT;
514
+ const tourCodexReasoning = state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING;
515
+ const tourCodexFast = state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST;
516
+ const guideClaudeEffort = state.guideClaude.perModel[state.guideClaude.model]?.effort ?? DEFAULT_GUIDE_CLAUDE_EFFORT;
517
+ const guideCodexReasoning = state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING;
518
+
519
+ return {
520
+ selectedMode: state.selectedMode,
521
+ reviewEngine: state.reviewEngine,
522
+ reviewProfileId: state.reviewProfileByEngine[state.reviewEngine] ?? BUILTIN_DEFAULT_PROFILE,
523
+ tourEngine: state.tourEngine,
524
+ guideEngine: state.guideEngine,
525
+ claudeModel: state.claude.model,
526
+ claudeEffort,
527
+ codexModel: state.codex.model,
528
+ codexReasoning,
529
+ codexFast,
530
+ cursorModel: state.cursor.model,
531
+ opencodeModel: state.opencode.model,
532
+ piModel: state.pi.model,
533
+ piThinking: state.pi.thinking,
534
+ copilotModel: state.copilot.model,
535
+ tourClaudeModel: state.tourClaude.model,
536
+ tourClaudeEffort,
537
+ tourCodexModel: state.tourCodex.model,
538
+ tourCodexReasoning,
539
+ tourCodexFast,
540
+ guideClaudeModel: state.guideClaude.model,
541
+ guideClaudeEffort,
542
+ guideCodexModel: state.guideCodex.model,
543
+ guideCodexReasoning,
544
+ guideCursorModel: state.guideCursor.model,
545
+ guideOpencodeModel: state.guideOpencode.model,
546
+ guidePiModel: state.guidePi.model,
547
+ guidePiThinking: state.guidePi.thinking,
548
+ guideCopilotModel: state.guideCopilot.model,
549
+ setSelectedMode,
550
+ setReviewEngine,
551
+ setReviewProfileId,
552
+ setTourEngine,
553
+ setGuideEngine,
554
+ setClaudeModel,
555
+ setClaudeEffort,
556
+ setCodexModel,
557
+ setCodexReasoning,
558
+ setCodexFast,
559
+ setCursorModel,
560
+ setOpencodeModel,
561
+ setPiModel,
562
+ setPiThinking,
563
+ setCopilotModel,
564
+ setTourClaudeModel,
565
+ setTourClaudeEffort,
566
+ setTourCodexModel,
567
+ setTourCodexReasoning,
568
+ setTourCodexFast,
569
+ setGuideClaudeModel,
570
+ setGuideClaudeEffort,
571
+ setGuideCodexModel,
572
+ setGuideCodexReasoning,
573
+ setGuideCursorModel,
574
+ setGuideOpencodeModel,
575
+ setGuidePiModel,
576
+ setGuidePiThinking,
577
+ setGuideCopilotModel,
578
+ };
579
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Hook for fetching and validating OpenCode agents
3
+ */
4
+
5
+ import { useState, useEffect, useCallback } from 'react';
6
+ import type { Origin } from '@plannotator/core/agents';
7
+ import { getAgentSwitchSettings } from '../utils/agentSwitch';
8
+
9
+ export interface Agent {
10
+ id: string;
11
+ name: string;
12
+ description?: string;
13
+ }
14
+
15
+ export interface UseAgentsResult {
16
+ agents: Agent[];
17
+ isLoading: boolean;
18
+ /** Validate if an agent exists (case-insensitive) */
19
+ validateAgent: (agentName: string) => boolean;
20
+ /** Get warning message if current settings point to invalid agent, null if valid */
21
+ getAgentWarning: () => string | null;
22
+ }
23
+
24
+ /**
25
+ * Fetch available agents from OpenCode API
26
+ * Only fetches when origin is 'opencode'
27
+ */
28
+ export function useAgents(origin: Origin | null): UseAgentsResult {
29
+ const [agents, setAgents] = useState<Agent[]>([]);
30
+ const [isLoading, setIsLoading] = useState(false);
31
+
32
+ useEffect(() => {
33
+ if (origin !== 'opencode') return;
34
+
35
+ setIsLoading(true);
36
+ fetch('/api/agents')
37
+ .then(res => res.json())
38
+ .then((data: { agents?: Agent[] }) => {
39
+ if (data.agents?.length) {
40
+ setAgents(data.agents);
41
+ }
42
+ })
43
+ .catch(() => {
44
+ setAgents([]);
45
+ })
46
+ .finally(() => {
47
+ setIsLoading(false);
48
+ });
49
+ }, [origin]);
50
+
51
+ const validateAgent = useCallback((agentName: string): boolean => {
52
+ if (agents.length === 0) return true; // No agents = can't validate, assume ok
53
+ return agents.some(a => a.id.toLowerCase() === agentName.toLowerCase());
54
+ }, [agents]);
55
+
56
+ const getAgentWarning = useCallback((): string | null => {
57
+ if (agents.length === 0) return null; // Can't validate without agents
58
+
59
+ const settings = getAgentSwitchSettings();
60
+
61
+ if (settings.switchTo === 'disabled') {
62
+ return null;
63
+ }
64
+
65
+ if (settings.switchTo === 'custom') {
66
+ if (settings.customName) {
67
+ const exists = validateAgent(settings.customName);
68
+ if (!exists) {
69
+ return `Agent "${settings.customName}" not found in OpenCode. It may cause errors.`;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // Regular agent selection
76
+ const exists = validateAgent(settings.switchTo);
77
+ if (!exists) {
78
+ return `Agent "${settings.switchTo}" not found in OpenCode. Select another or it may cause errors.`;
79
+ }
80
+
81
+ return null;
82
+ }, [agents, validateAgent]);
83
+
84
+ return {
85
+ agents,
86
+ isLoading,
87
+ validateAgent,
88
+ getAgentWarning,
89
+ };
90
+ }