@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,1294 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
+ import {
3
+ Bot,
4
+ Play,
5
+ X,
6
+ Square,
7
+ Loader2,
8
+ CheckCircle2,
9
+ AlertTriangle,
10
+ Skull,
11
+ ExternalLink,
12
+ Zap,
13
+ Plus,
14
+ Search,
15
+ } from 'lucide-react';
16
+ import type { AgentJobInfo, AgentCapabilities } from '../types';
17
+ import { isTerminalStatus } from '@plannotator/core/agent-jobs';
18
+ import { cn } from '../lib/utils';
19
+ import { ReviewAgentsIcon } from './ReviewAgentsIcon';
20
+ import { ClaudeIcon, CodexIcon, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons';
21
+ import { useAgentSettings } from '../hooks/useAgentSettings';
22
+ import type { AgentEngine, AgentMode, ReviewEngine } from '../hooks/useAgentSettings';
23
+ import type { AgentLaunchParams } from '../hooks/useAgentJobs';
24
+ import { ConfigRow, SegmentedPicker, Toggle, SelectMenu } from './AgentControls';
25
+
26
+ export type { AgentLaunchParams } from '../hooks/useAgentJobs';
27
+
28
+ // --- Agent option catalogs (shared across review + tour engine dropdowns) ---
29
+
30
+ export const CLAUDE_MODELS: Array<{ value: string; label: string }> = [
31
+ { value: 'claude-fable-5', label: 'Fable 5' },
32
+ { value: 'claude-opus-4-8', label: 'Opus 4.8' },
33
+ { value: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)' },
34
+ { value: 'claude-sonnet-5', label: 'Sonnet 5' },
35
+ { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
36
+ { value: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)' },
37
+ { value: 'claude-opus-4-7', label: 'Opus 4.7' },
38
+ { value: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)' },
39
+ { value: 'claude-opus-4-6', label: 'Opus 4.6' },
40
+ { value: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)' },
41
+ { value: 'claude-haiku-4-5', label: 'Haiku 4.5' },
42
+ ];
43
+
44
+ export const CLAUDE_EFFORT: Array<{ value: string; label: string }> = [
45
+ { value: 'low', label: 'Low' },
46
+ { value: 'medium', label: 'Medium' },
47
+ { value: 'high', label: 'High' },
48
+ { value: 'xhigh', label: 'XHigh' },
49
+ { value: 'max', label: 'Max' },
50
+ ];
51
+
52
+ export const CODEX_MODELS: Array<{ value: string; label: string }> = [
53
+ { value: 'gpt-5.5', label: 'GPT-5.5' },
54
+ { value: 'gpt-5.4', label: 'GPT-5.4' },
55
+ { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
56
+ { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
57
+ { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
58
+ { value: 'gpt-5.2', label: 'GPT-5.2' },
59
+ { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
60
+ { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
61
+ { value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
62
+ ];
63
+
64
+ export const CODEX_REASONING: Array<{ value: string; label: string }> = [
65
+ { value: 'minimal', label: 'Minimal' },
66
+ { value: 'low', label: 'Low' },
67
+ { value: 'medium', label: 'Medium' },
68
+ { value: 'high', label: 'High' },
69
+ { value: 'xhigh', label: 'XHigh' },
70
+ ];
71
+
72
+ // Tour/guide Claude catalog: the CLI's latest-resolving aliases on top
73
+ // (verified against `claude --help`: "Provide an alias for the latest model
74
+ // (e.g. 'fable', 'opus', or 'sonnet') or a model's full name"), then every
75
+ // pinned version from the review catalog.
76
+ // Also reused by GuideEmptyState (packages/review-editor).
77
+ export const TOUR_CLAUDE_MODELS: Array<{ value: string; label: string }> = [
78
+ { value: 'sonnet', label: 'Sonnet (latest)' },
79
+ { value: 'opus', label: 'Opus (latest)' },
80
+ { value: 'fable', label: 'Fable (latest)' },
81
+ ...CLAUDE_MODELS,
82
+ ];
83
+
84
+ // Fallback Cursor model catalog (just `auto`). The real, account-specific list
85
+ // is discovered server-side via `agent models` and delivered on the cursor
86
+ // capability; the component prefers that and only falls back to this when the
87
+ // server reports no models (e.g. unauthenticated CLI). Used by formatModel for
88
+ // job-card labels where the live list isn't threaded.
89
+ const CURSOR_MODELS: Array<{ value: string; label: string }> = [
90
+ { value: 'auto', label: 'Auto' },
91
+ ];
92
+
93
+ // Fallback OpenCode model catalog. The real list is discovered server-side via
94
+ // `opencode models` and delivered on the opencode capability; empty value means
95
+ // "use OpenCode's configured default".
96
+ const OPENCODE_MODELS: Array<{ value: string; label: string }> = [
97
+ { value: '', label: 'Default' },
98
+ ];
99
+
100
+ // Fallback Pi model catalog. The real list is discovered server-side and
101
+ // delivered on the pi capability; empty value means "use Pi's own default".
102
+ const PI_MODELS: Array<{ value: string; label: string }> = [
103
+ { value: '', label: 'Default' },
104
+ ];
105
+
106
+ // Fallback Copilot model catalog. The real list is discovered server-side via
107
+ // `copilot help config`; empty value means "let Copilot pick".
108
+ const COPILOT_MODELS: Array<{ value: string; label: string }> = [
109
+ { value: '', label: 'Default' },
110
+ ];
111
+
112
+ // Pi's unified reasoning knob (`--thinking`), applied to whichever model is
113
+ // selected. xhigh is accepted only by codex-max models.
114
+ export const PI_THINKING: Array<{ value: string; label: string }> = [
115
+ { value: 'off', label: 'Off' },
116
+ { value: 'minimal', label: 'Min' },
117
+ { value: 'low', label: 'Low' },
118
+ { value: 'medium', label: 'Med' },
119
+ { value: 'high', label: 'High' },
120
+ { value: 'xhigh', label: 'XHigh' },
121
+ ];
122
+
123
+ const MODE_LABEL: Record<AgentMode, string> = {
124
+ review: 'Code Review',
125
+ tour: 'Code Tour',
126
+ guide: 'Guided Review',
127
+ };
128
+
129
+ const ENGINE_LABEL: Record<AgentEngine, string> = {
130
+ claude: 'Claude',
131
+ codex: 'Codex',
132
+ };
133
+
134
+ const ENGINE_ICON: Record<AgentEngine, React.FC<{ className?: string }>> = {
135
+ claude: ClaudeIcon,
136
+ codex: CodexIcon,
137
+ };
138
+
139
+ // Review-only label map. Keeps Tour's narrow AgentEngine maps valid while the
140
+ // review surface offers the wider set (Cursor/OpenCode). Exported so the guide
141
+ // takeover surfaces (GuideScreen, GuideEmptyState in packages/review-editor)
142
+ // share this one source of truth instead of keeping their own copies in sync.
143
+ export const REVIEW_ENGINE_LABEL: Record<ReviewEngine, string> = {
144
+ claude: 'Claude',
145
+ codex: 'Codex',
146
+ cursor: 'Cursor',
147
+ opencode: 'OpenCode',
148
+ pi: 'Pi',
149
+ copilot: 'Copilot',
150
+ };
151
+
152
+ // Review-only icon map — the wide set. Tour keeps the narrow ENGINE_ICON.
153
+ const REVIEW_ENGINE_ICON: Record<ReviewEngine, React.FC<{ className?: string }>> = {
154
+ claude: ClaudeIcon,
155
+ codex: CodexIcon,
156
+ cursor: CursorIcon,
157
+ opencode: OpenCodeIcon,
158
+ pi: PiIcon,
159
+ copilot: CopilotIcon,
160
+ };
161
+
162
+ export type AgentLaunchResult = AgentJobInfo | null | void;
163
+
164
+ interface AgentsTabProps {
165
+ jobs: AgentJobInfo[];
166
+ capabilities: AgentCapabilities | null;
167
+ onLaunch: (params: AgentLaunchParams) => AgentLaunchResult | Promise<AgentLaunchResult>;
168
+ onKillJob: (id: string) => void;
169
+ onKillAll: () => void;
170
+ externalAnnotations: Array<{ source?: string }>;
171
+ onOpenJobDetail?: (jobId: string) => void;
172
+ onOpenGuide?: (jobId: string) => void;
173
+ /** Whether the current diff has any files a guide could reference — mirrors
174
+ * the review-editor header's `hasSearchableFiles` gate (the "Guide" badge
175
+ * and its keyboard shortcut). A guide organizes changed files into
176
+ * chapters, so with none available there is nothing for it to do; default
177
+ * true so callers that don't pass it (e.g. the plan editor, which has no
178
+ * concept of "files") see unchanged behavior. */
179
+ guideLaunchable?: boolean;
180
+ /** Whether a given guide job's artifact can be opened from HERE — i.e. it
181
+ * belongs to the review context currently on screen. The job list spans
182
+ * every context visited this session, but opening only sets
183
+ * activeGuideJobId/guideOpen (it does NOT switch PRs), so a cross-context
184
+ * "Open guide" would land on the wrong guide or the empty state. Default
185
+ * undefined ⇒ always openable (non-review callers have no contexts). */
186
+ canOpenGuideJob?: (job: AgentJobInfo) => boolean;
187
+ }
188
+
189
+ // --- Duration display ---
190
+ // Exported so other agent-job surfaces (e.g. GuideGenerating in
191
+ // review-editor) share this one implementation instead of keeping their own
192
+ // copies in sync.
193
+
194
+ export function formatDuration(ms: number): string {
195
+ const seconds = Math.floor(ms / 1000);
196
+ if (seconds < 60) return `${seconds}s`;
197
+ const minutes = Math.floor(seconds / 60);
198
+ const remainingSeconds = seconds % 60;
199
+ return `${minutes}m ${remainingSeconds}s`;
200
+ }
201
+
202
+ export function ElapsedTime({ startedAt }: { startedAt: number }) {
203
+ const [, setTick] = useState(0);
204
+ useEffect(() => {
205
+ const timer = setInterval(() => setTick((t) => t + 1), 1000);
206
+ return () => clearInterval(timer);
207
+ }, []);
208
+ return <>{formatDuration(Date.now() - startedAt)}</>;
209
+ }
210
+
211
+ // --- Status square (colored tile + lucide glyph, matches the prototype) ---
212
+
213
+ const JOB_STATUS_BG: Record<AgentJobInfo['status'], string> = {
214
+ starting: 'bg-muted-foreground/10',
215
+ running: 'bg-primary/10',
216
+ done: 'bg-green-500/10',
217
+ failed: 'bg-red-500/10',
218
+ killed: 'bg-orange-500/10',
219
+ };
220
+
221
+ const JOB_STATUS_ICON: Record<AgentJobInfo['status'], React.ReactNode> = {
222
+ starting: <Loader2 className="animate-spin text-muted-foreground" size={10} />,
223
+ running: <Loader2 className="animate-spin text-primary" size={10} />,
224
+ done: <CheckCircle2 className="text-green-600 dark:text-green-400" size={10} />,
225
+ failed: <AlertTriangle className="text-red-600 dark:text-red-400" size={10} />,
226
+ killed: <Skull className="text-orange-600 dark:text-orange-400" size={10} />,
227
+ };
228
+
229
+ function StatusSquare({ status }: { status: AgentJobInfo['status'] }) {
230
+ return (
231
+ <div
232
+ className={cn(
233
+ 'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md',
234
+ JOB_STATUS_BG[status],
235
+ )}
236
+ >
237
+ {JOB_STATUS_ICON[status]}
238
+ </div>
239
+ );
240
+ }
241
+
242
+ // --- Provider badge ---
243
+
244
+ // Lookup a human label from the catalogs; fall back to the raw id.
245
+ function catalogLabel(list: Array<{ value: string; label: string }>, value: string): string {
246
+ return list.find((o) => o.value === value)?.label ?? value;
247
+ }
248
+
249
+ function formatModel(provider: string, engine: string | undefined, model: string): string {
250
+ if (provider === 'cursor') return catalogLabel(CURSOR_MODELS, model);
251
+ if (provider === 'opencode') return model ? model : 'Default';
252
+ if (provider === 'pi') return model || 'Default';
253
+ if (provider === 'copilot') return model || 'Default';
254
+ if (provider === 'codex' || engine === 'codex') return catalogLabel(CODEX_MODELS, model);
255
+ if ((provider === 'tour' || provider === 'guide') && engine === 'claude') return catalogLabel(TOUR_CLAUDE_MODELS, model);
256
+ if (provider === 'tour' || provider === 'guide') {
257
+ if (engine === 'cursor') return catalogLabel(CURSOR_MODELS, model);
258
+ if (engine === 'opencode' || engine === 'pi' || engine === 'copilot') return model || 'Default';
259
+ }
260
+ return catalogLabel(CLAUDE_MODELS, model);
261
+ }
262
+
263
+ function formatThinking(value: string): string {
264
+ return catalogLabel(PI_THINKING, value);
265
+ }
266
+
267
+
268
+ function formatEffort(value: string): string {
269
+ return catalogLabel(CLAUDE_EFFORT, value);
270
+ }
271
+
272
+ function formatReasoning(value: string): string {
273
+ return catalogLabel(CODEX_REASONING, value);
274
+ }
275
+
276
+ // --- Add-a-review dialog: a type-ahead picker over every discovered skill ---
277
+
278
+ interface CatalogSkill {
279
+ name: string;
280
+ root: string;
281
+ sourcePath: string;
282
+ enabled: boolean;
283
+ }
284
+
285
+ function AddReviewDialog({
286
+ onClose,
287
+ onEnabled,
288
+ }: {
289
+ onClose: () => void;
290
+ onEnabled: (name: string) => void;
291
+ }) {
292
+ const [skills, setSkills] = useState<CatalogSkill[] | null>(null);
293
+ const [query, setQuery] = useState('');
294
+ const [busy, setBusy] = useState<string | null>(null);
295
+ const [error, setError] = useState<string | null>(null);
296
+
297
+ useEffect(() => {
298
+ let alive = true;
299
+ fetch('/api/agents/skills')
300
+ .then((r) => r.json())
301
+ .then((d) => {
302
+ if (alive) setSkills(Array.isArray(d.skills) ? d.skills : []);
303
+ })
304
+ .catch(() => {
305
+ if (alive) setSkills([]);
306
+ });
307
+ return () => {
308
+ alive = false;
309
+ };
310
+ }, []);
311
+
312
+ const candidates = useMemo(() => {
313
+ const q = query.trim().toLowerCase();
314
+ return (skills ?? [])
315
+ .filter((s) => !s.enabled)
316
+ .filter((s) => (q ? s.name.toLowerCase().includes(q) : true));
317
+ }, [skills, query]);
318
+
319
+ const enable = async (name: string) => {
320
+ setBusy(name);
321
+ setError(null);
322
+ try {
323
+ const res = await fetch('/api/agents/review-skills', {
324
+ method: 'POST',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({ name }),
327
+ });
328
+ if (!res.ok) {
329
+ const d = await res.json().catch(() => ({}));
330
+ throw new Error(d.error ?? 'Could not add review.');
331
+ }
332
+ onEnabled(name);
333
+ } catch (e) {
334
+ setError(e instanceof Error ? e.message : 'Could not add review.');
335
+ setBusy(null);
336
+ }
337
+ };
338
+
339
+ return (
340
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true">
341
+ <div className="absolute inset-0 bg-black/40" onClick={onClose} />
342
+ <div className="relative z-10 flex max-h-[70vh] w-full max-w-sm flex-col overflow-hidden rounded-xl bg-card shadow-[var(--card-shadow)] ring-1 ring-border/20">
343
+ <div className="flex items-center justify-between border-b border-border/40 px-3 py-2.5">
344
+ <span className="text-[12px] font-medium text-foreground">Add a review</span>
345
+ <button type="button" onClick={onClose} className="text-muted-foreground/50 hover:text-foreground">
346
+ <X size={13} />
347
+ </button>
348
+ </div>
349
+
350
+ <div className="border-b border-border/40 p-2">
351
+ <div className="flex items-center gap-2 rounded-lg border border-border/30 bg-surface-1/30 px-2.5 py-1.5">
352
+ <Search className="shrink-0 text-muted-foreground/40" size={12} />
353
+ <input
354
+ autoFocus
355
+ value={query}
356
+ onChange={(e) => setQuery(e.target.value)}
357
+ placeholder="Filter your skills"
358
+ className="min-w-0 flex-1 bg-transparent text-[12px] text-foreground/90 outline-none placeholder:text-muted-foreground/40"
359
+ />
360
+ </div>
361
+ </div>
362
+
363
+ <div className="flex-1 overflow-y-auto p-1.5">
364
+ {skills === null ? (
365
+ <div className="flex items-center justify-center py-8 text-muted-foreground/40">
366
+ <Loader2 className="animate-spin" size={14} />
367
+ </div>
368
+ ) : candidates.length === 0 ? (
369
+ <p className="px-2 py-8 text-center text-[11px] text-muted-foreground/40">
370
+ {query ? 'No matching skills.' : 'No skills left to add.'}
371
+ </p>
372
+ ) : (
373
+ candidates.map((s) => (
374
+ <button
375
+ key={`${s.root}:${s.name}`}
376
+ type="button"
377
+ disabled={busy !== null}
378
+ onClick={() => enable(s.name)}
379
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-surface-1/50 disabled:opacity-50"
380
+ >
381
+ <span className="min-w-0 flex-1 truncate text-[12px] text-foreground/90">{s.name}</span>
382
+ <span className="shrink-0 text-[9px] uppercase tracking-wide text-muted-foreground/40">{s.root}</span>
383
+ {busy === s.name ? <Loader2 className="shrink-0 animate-spin" size={11} /> : <Plus className="shrink-0 text-muted-foreground/40" size={11} />}
384
+ </button>
385
+ ))
386
+ )}
387
+ </div>
388
+
389
+ {error && <p className="border-t border-border/40 px-3 py-2 text-[10px] text-red-500">{error}</p>}
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // --- Job card ---
396
+
397
+ function JobCard({
398
+ job,
399
+ annotationCount,
400
+ onKill,
401
+ expanded,
402
+ onToggle,
403
+ onViewDetails,
404
+ onOpenGuide,
405
+ }: {
406
+ job: AgentJobInfo;
407
+ annotationCount: number;
408
+ onKill: () => void;
409
+ expanded: boolean;
410
+ onToggle: () => void;
411
+ onViewDetails?: () => void;
412
+ onOpenGuide?: () => void;
413
+ }) {
414
+ const isTerminal = isTerminalStatus(job.status);
415
+
416
+ return (
417
+ <div
418
+ className={cn(
419
+ 'group relative rounded-lg px-2.5 py-2 transition-colors cursor-pointer hover:bg-surface-1/50',
420
+ expanded && 'bg-surface-1/40',
421
+ )}
422
+ onClick={onViewDetails ? () => onViewDetails() : (isTerminal ? onToggle : undefined)}
423
+ >
424
+ <div className="flex items-start gap-2.5">
425
+ <StatusSquare status={job.status} />
426
+ <div className="min-w-0 flex-1">
427
+ <div className="flex items-center gap-1.5">
428
+ <span className="truncate font-medium text-[12px] text-foreground">{job.label}</span>
429
+ {onViewDetails && <ExternalLink className="shrink-0 text-muted-foreground/30" size={9} />}
430
+ </div>
431
+ <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[9px] text-muted-foreground/50">
432
+ <span className="rounded bg-surface-1 px-1 py-px">{job.provider}</span>
433
+ {job.model && (
434
+ <span className="rounded bg-surface-1 px-1 py-px font-mono">{formatModel(job.provider, job.engine, job.model)}</span>
435
+ )}
436
+ {job.effort && <span className="rounded bg-surface-1 px-1 py-px">{formatEffort(job.effort)}</span>}
437
+ {job.reasoningEffort && <span className="rounded bg-surface-1 px-1 py-px">{formatReasoning(job.reasoningEffort)}</span>}
438
+ {job.thinking && <span className="rounded bg-surface-1 px-1 py-px">{formatThinking(job.thinking)}</span>}
439
+ {job.fastMode && (
440
+ <span className="rounded bg-amber-500/10 px-1 py-px text-amber-600 dark:text-amber-400">
441
+ <Zap className="inline" size={7} /> fast
442
+ </span>
443
+ )}
444
+ <span className="text-muted-foreground/30">·</span>
445
+ <span className="tabular-nums">
446
+ {isTerminal && job.endedAt ? formatDuration(job.endedAt - job.startedAt) : <ElapsedTime startedAt={job.startedAt} />}
447
+ </span>
448
+ {annotationCount > 0 && (
449
+ <>
450
+ <span className="text-muted-foreground/30">·</span>
451
+ <span className="tabular-nums">{annotationCount} finding{annotationCount !== 1 ? 's' : ''}</span>
452
+ </>
453
+ )}
454
+ </div>
455
+ </div>
456
+ </div>
457
+
458
+ {!isTerminal && (
459
+ <button
460
+ onClick={(e) => {
461
+ e.stopPropagation();
462
+ onKill();
463
+ }}
464
+ className="absolute top-1.5 right-1.5 rounded p-1 text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
465
+ title="Kill agent"
466
+ >
467
+ <X size={12} />
468
+ </button>
469
+ )}
470
+
471
+ {/* Error details — fallback for when the dockview detail panel is not available */}
472
+ {!onViewDetails && job.status === 'failed' && job.error && expanded && (
473
+ <div className="mt-2 rounded bg-destructive/5 border border-destructive/20 p-2">
474
+ <pre className="max-h-24 overflow-y-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-destructive/80">
475
+ {job.error}
476
+ </pre>
477
+ </div>
478
+ )}
479
+
480
+ {/* Open guide — a completed guide job's direct affordance into the takeover. */}
481
+ {job.provider === 'guide' && job.status === 'done' && onOpenGuide && (
482
+ <button
483
+ onClick={(e) => {
484
+ e.stopPropagation();
485
+ onOpenGuide();
486
+ }}
487
+ className="mt-2 flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 font-medium text-[10px] text-primary transition-colors hover:bg-primary/20"
488
+ >
489
+ Open guide
490
+ </button>
491
+ )}
492
+ </div>
493
+ );
494
+ }
495
+
496
+ function PendingLaunchCard({
497
+ label,
498
+ provider,
499
+ startedAt,
500
+ }: {
501
+ label: string;
502
+ provider?: string;
503
+ startedAt: number;
504
+ }) {
505
+ return (
506
+ <div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/10">
507
+ <div className="flex items-start gap-2.5">
508
+ <StatusSquare status="starting" />
509
+ <div className="min-w-0 flex-1">
510
+ <div className="flex items-center gap-1.5">
511
+ <span className="truncate font-medium text-[12px] text-foreground">{label}</span>
512
+ </div>
513
+ <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[9px] text-muted-foreground/50">
514
+ {provider && <span className="rounded bg-surface-1 px-1 py-px">{provider}</span>}
515
+ <span>requesting launch</span>
516
+ <span className="text-muted-foreground/30">·</span>
517
+ <span className="tabular-nums"><ElapsedTime startedAt={startedAt} /></span>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ // --- Main component ---
526
+
527
+ export const AgentsTab: React.FC<AgentsTabProps> = ({
528
+ jobs,
529
+ capabilities,
530
+ onLaunch,
531
+ onKillJob,
532
+ onKillAll,
533
+ externalAnnotations,
534
+ onOpenJobDetail,
535
+ onOpenGuide,
536
+ guideLaunchable = true,
537
+ canOpenGuideJob,
538
+ }) => {
539
+ const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
540
+ const [pendingLaunch, setPendingLaunch] = useState<{ label: string; provider?: string; startedAt: number } | null>(null);
541
+ const [launchError, setLaunchError] = useState<string | null>(null);
542
+ const launchingRef = useRef(false);
543
+ const settings = useAgentSettings();
544
+ const {
545
+ selectedMode,
546
+ reviewEngine,
547
+ reviewProfileId,
548
+ tourEngine,
549
+ guideEngine,
550
+ claudeModel,
551
+ claudeEffort,
552
+ codexModel,
553
+ codexReasoning,
554
+ codexFast,
555
+ cursorModel,
556
+ opencodeModel,
557
+ piModel,
558
+ piThinking,
559
+ copilotModel,
560
+ tourClaudeModel,
561
+ tourClaudeEffort,
562
+ tourCodexModel,
563
+ tourCodexReasoning,
564
+ tourCodexFast,
565
+ guideClaudeModel,
566
+ guideClaudeEffort,
567
+ guideCodexModel,
568
+ guideCodexReasoning,
569
+ guideCursorModel,
570
+ guideOpencodeModel,
571
+ guidePiModel,
572
+ guidePiThinking,
573
+ guideCopilotModel,
574
+ setSelectedMode,
575
+ setReviewEngine,
576
+ setReviewProfileId,
577
+ setTourEngine,
578
+ setGuideEngine,
579
+ setClaudeModel,
580
+ setClaudeEffort,
581
+ setCodexModel,
582
+ setCodexReasoning,
583
+ setCodexFast,
584
+ setCursorModel,
585
+ setOpencodeModel,
586
+ setPiModel,
587
+ setPiThinking,
588
+ setCopilotModel,
589
+ setTourClaudeModel,
590
+ setTourClaudeEffort,
591
+ setTourCodexModel,
592
+ setTourCodexReasoning,
593
+ setTourCodexFast,
594
+ setGuideClaudeModel,
595
+ setGuideClaudeEffort,
596
+ setGuideCodexModel,
597
+ setGuideCodexReasoning,
598
+ setGuideCursorModel,
599
+ setGuideOpencodeModel,
600
+ setGuidePiModel,
601
+ setGuidePiThinking,
602
+ setGuideCopilotModel,
603
+ } = settings;
604
+
605
+ // Review profiles (built-in default plus the user's enabled skills). Loaded
606
+ // from the discovery endpoint and refreshed after a skill is added.
607
+ const [reviewProfiles, setReviewProfiles] = useState<Array<{ id: string; label: string; default?: boolean }>>([
608
+ { id: 'builtin:default', label: 'Default', default: true },
609
+ ]);
610
+ // Until the list has loaded we can't tell a saved custom pick from a removed
611
+ // one, so a launch in that window would silently fall back to Default. Gate
612
+ // launch on this for a custom pick (see canLaunch).
613
+ const [profilesLoaded, setProfilesLoaded] = useState(false);
614
+ const [addReviewOpen, setAddReviewOpen] = useState(false);
615
+
616
+ const refreshReviewProfiles = useCallback(() => {
617
+ fetch('/api/agents/review-profiles')
618
+ .then((r) => r.json())
619
+ .then((d) => {
620
+ if (Array.isArray(d.profiles) && d.profiles.length > 0) setReviewProfiles(d.profiles);
621
+ })
622
+ .catch(() => {})
623
+ .finally(() => setProfilesLoaded(true));
624
+ }, []);
625
+
626
+ useEffect(() => {
627
+ refreshReviewProfiles();
628
+ }, [refreshReviewProfiles]);
629
+
630
+ const claudeAvailable = capabilities?.providers.some((p) => p.id === 'claude' && p.available) ?? false;
631
+ const codexAvailable = capabilities?.providers.some((p) => p.id === 'codex' && p.available) ?? false;
632
+ const tourAvailable = capabilities?.providers.some((p) => p.id === 'tour' && p.available) ?? false;
633
+ const guideAvailable = capabilities?.providers.some((p) => p.id === 'guide' && p.available) ?? false;
634
+ const cursorAvailable = capabilities?.providers.some((p) => p.id === 'cursor' && p.available) ?? false;
635
+ const opencodeAvailable = capabilities?.providers.some((p) => p.id === 'opencode' && p.available) ?? false;
636
+ const piAvailable = capabilities?.providers.some((p) => p.id === 'pi' && p.available) ?? false;
637
+ const copilotAvailable = capabilities?.providers.some((p) => p.id === 'copilot' && p.available) ?? false;
638
+
639
+ // Cursor's model catalog is account-specific and discovered server-side, so
640
+ // prefer the live list from the capability; fall back to `auto`-only when the
641
+ // server reports none (e.g. unauthenticated CLI).
642
+ const cursorModels = useMemo<Array<{ value: string; label: string }>>(() => {
643
+ const discovered = capabilities?.providers.find((p) => p.id === 'cursor')?.models ?? [];
644
+ const opts = discovered.map((m) => ({ value: m.id, label: m.label }));
645
+ return opts.length > 0 ? opts : CURSOR_MODELS;
646
+ }, [capabilities]);
647
+
648
+ // OpenCode models discovered server-side via `opencode models`; prepend the
649
+ // "Default" option so the user can leave the model to OpenCode's config.
650
+ const opencodeModels = useMemo<Array<{ value: string; label: string }>>(() => {
651
+ const discovered = capabilities?.providers.find((p) => p.id === 'opencode')?.models ?? [];
652
+ const opts = discovered.map((m) => ({ value: m.id, label: m.label }));
653
+ return opts.length > 0 ? [...OPENCODE_MODELS, ...opts] : OPENCODE_MODELS;
654
+ }, [capabilities]);
655
+
656
+ // Pi models discovered server-side; prepend the "Default" option so the user
657
+ // can leave the model to Pi's own default (same convention as OpenCode).
658
+ const piModels = useMemo<Array<{ value: string; label: string }>>(() => {
659
+ const discovered = capabilities?.providers.find((p) => p.id === 'pi')?.models ?? [];
660
+ const opts = discovered.map((m) => ({ value: m.id, label: m.label }));
661
+ return opts.length > 0 ? [...PI_MODELS, ...opts] : PI_MODELS;
662
+ }, [capabilities]);
663
+
664
+ // Copilot models discovered server-side via `copilot help config`; prepend
665
+ // the "Default" option so the user can leave the model to Copilot's own pick.
666
+ const copilotModels = useMemo<Array<{ value: string; label: string }>>(() => {
667
+ const discovered = capabilities?.providers.find((p) => p.id === 'copilot')?.models ?? [];
668
+ const opts = discovered.map((m) => ({ value: m.id, label: m.label }));
669
+ return opts.length > 0 ? [...COPILOT_MODELS, ...opts] : COPILOT_MODELS;
670
+ }, [capabilities]);
671
+
672
+ // Tour engines (narrow union). Cursor is NOT included here — it is review-only.
673
+ const availableEngines = useMemo<AgentEngine[]>(() => {
674
+ const engines: AgentEngine[] = [];
675
+ if (claudeAvailable) engines.push('claude');
676
+ if (codexAvailable) engines.push('codex');
677
+ return engines;
678
+ }, [claudeAvailable, codexAvailable]);
679
+
680
+ // Review engines (wide union) = tour engines + cursor/opencode when available.
681
+ const availableReviewEngines = useMemo<ReviewEngine[]>(() => {
682
+ const engines: ReviewEngine[] = [...availableEngines];
683
+ if (cursorAvailable) engines.push('cursor');
684
+ if (opencodeAvailable) engines.push('opencode');
685
+ if (piAvailable) engines.push('pi');
686
+ if (copilotAvailable) engines.push('copilot');
687
+ return engines;
688
+ }, [availableEngines, cursorAvailable, opencodeAvailable, piAvailable, copilotAvailable]);
689
+
690
+ const availableModes = useMemo<AgentMode[]>(() => {
691
+ const modes: AgentMode[] = [];
692
+ if (availableReviewEngines.length > 0) modes.push('review');
693
+ if (tourAvailable && availableEngines.length > 0) modes.push('tour');
694
+ // Guide runs on the wide union — marker engines generate guides too.
695
+ // Also gated on guideLaunchable: a guide organizes changed files into
696
+ // chapters, so it has nothing to do against a diff with no files (same
697
+ // gate the review-editor header applies to the "Guide" badge/shortcut).
698
+ if (guideAvailable && availableReviewEngines.length > 0 && guideLaunchable) modes.push('guide');
699
+ return modes;
700
+ }, [availableReviewEngines.length, availableEngines.length, tourAvailable, guideAvailable, guideLaunchable]);
701
+ // (availableReviewEngines.length covers the guide gate above.)
702
+
703
+ const firstAvailableEngine = availableEngines[0] ?? null;
704
+ const firstAvailableReviewEngine = availableReviewEngines[0] ?? null;
705
+ const engineAvailable = (engine: AgentEngine) => engine === 'claude' ? claudeAvailable : codexAvailable;
706
+ const reviewEngineAvailable = (engine: ReviewEngine) =>
707
+ engine === 'cursor' ? cursorAvailable
708
+ : engine === 'opencode' ? opencodeAvailable
709
+ : engine === 'pi' ? piAvailable
710
+ : engine === 'copilot' ? copilotAvailable
711
+ : engineAvailable(engine);
712
+
713
+ // Reconcile mode + engine choices against live capabilities. Runs when
714
+ // capabilities change or the stored selection becomes invalid.
715
+ useEffect(() => {
716
+ if (!capabilities || availableModes.length === 0) return;
717
+ if (!selectedMode || !availableModes.includes(selectedMode)) {
718
+ setSelectedMode(availableModes[0]);
719
+ }
720
+ if (firstAvailableReviewEngine && !reviewEngineAvailable(reviewEngine)) {
721
+ setReviewEngine(firstAvailableReviewEngine);
722
+ }
723
+ if (firstAvailableEngine && !engineAvailable(tourEngine)) {
724
+ setTourEngine(firstAvailableEngine);
725
+ }
726
+ if (firstAvailableReviewEngine && !reviewEngineAvailable(guideEngine)) {
727
+ setGuideEngine(firstAvailableReviewEngine);
728
+ }
729
+ }, [
730
+ capabilities,
731
+ availableModes,
732
+ firstAvailableEngine,
733
+ firstAvailableReviewEngine,
734
+ selectedMode,
735
+ reviewEngine,
736
+ tourEngine,
737
+ guideEngine,
738
+ setSelectedMode,
739
+ setReviewEngine,
740
+ setTourEngine,
741
+ setGuideEngine,
742
+ ]);
743
+
744
+ // Reconcile the saved Cursor/OpenCode model against the live catalog: a
745
+ // persisted id can go stale after an account switch or discovery loss, and
746
+ // posting it would fail the launch. Collapse it to the first option (auto/
747
+ // Default) when it's no longer offered. Each effect also reconciles the
748
+ // guide-scoped counterpart against the SAME catalog and availability guard
749
+ // — the catalog is per-engine, not per-surface, so review and guide share
750
+ // it here even though their model selections are kept independent.
751
+ useEffect(() => {
752
+ // Only once the engine is actually available — before capabilities load,
753
+ // cursorModels is just the fallback, and reconciling here would wipe a valid
754
+ // saved model before the live catalog arrives.
755
+ if (!cursorAvailable) return;
756
+ if (!cursorModels.some((m) => m.value === cursorModel)) {
757
+ setCursorModel(cursorModels[0]?.value ?? 'auto');
758
+ }
759
+ if (!cursorModels.some((m) => m.value === guideCursorModel)) {
760
+ setGuideCursorModel(cursorModels[0]?.value ?? 'auto');
761
+ }
762
+ }, [cursorAvailable, cursorModels, cursorModel, setCursorModel, guideCursorModel, setGuideCursorModel]);
763
+ useEffect(() => {
764
+ if (!opencodeAvailable) return;
765
+ if (!opencodeModels.some((m) => m.value === opencodeModel)) {
766
+ setOpencodeModel(opencodeModels[0]?.value ?? '');
767
+ }
768
+ if (!opencodeModels.some((m) => m.value === guideOpencodeModel)) {
769
+ setGuideOpencodeModel(opencodeModels[0]?.value ?? '');
770
+ }
771
+ }, [opencodeAvailable, opencodeModels, opencodeModel, setOpencodeModel, guideOpencodeModel, setGuideOpencodeModel]);
772
+ useEffect(() => {
773
+ if (!piAvailable) return;
774
+ if (!piModels.some((m) => m.value === piModel)) {
775
+ setPiModel(piModels[0]?.value ?? '');
776
+ }
777
+ if (!piModels.some((m) => m.value === guidePiModel)) {
778
+ setGuidePiModel(piModels[0]?.value ?? '');
779
+ }
780
+ }, [piAvailable, piModels, piModel, setPiModel, guidePiModel, setGuidePiModel]);
781
+ useEffect(() => {
782
+ if (!copilotAvailable) return;
783
+ if (!copilotModels.some((m) => m.value === copilotModel)) {
784
+ setCopilotModel(copilotModels[0]?.value ?? '');
785
+ }
786
+ if (!copilotModels.some((m) => m.value === guideCopilotModel)) {
787
+ setGuideCopilotModel(copilotModels[0]?.value ?? '');
788
+ }
789
+ }, [copilotAvailable, copilotModels, copilotModel, setCopilotModel, guideCopilotModel, setGuideCopilotModel]);
790
+
791
+ // Annotation counts per job source
792
+ const annotationCounts = useMemo(() => {
793
+ const counts = new Map<string, number>();
794
+ for (const ann of externalAnnotations) {
795
+ if (ann.source) {
796
+ counts.set(ann.source, (counts.get(ann.source) ?? 0) + 1);
797
+ }
798
+ }
799
+ return counts;
800
+ }, [externalAnnotations]);
801
+
802
+ // Sort: running first, then by startedAt descending
803
+ const sortedJobs = useMemo(() => {
804
+ return [...jobs].sort((a, b) => {
805
+ const aRunning = !isTerminalStatus(a.status);
806
+ const bRunning = !isTerminalStatus(b.status);
807
+ if (aRunning !== bRunning) return aRunning ? -1 : 1;
808
+ return b.startedAt - a.startedAt;
809
+ });
810
+ }, [jobs]);
811
+
812
+ const runningCount = useMemo(
813
+ () => jobs.filter((j) => !isTerminalStatus(j.status)).length,
814
+ [jobs],
815
+ );
816
+
817
+ // A persisted review id can point at something not in the current list: the
818
+ // profiles may not be loaded yet, or the skill was removed by hand. Treat
819
+ // anything not in the list as Default, for both the dropdown and the launch.
820
+ const effectiveReviewProfileId = reviewProfiles.some((p) => p.id === reviewProfileId)
821
+ ? reviewProfileId
822
+ : 'builtin:default';
823
+
824
+ type LaunchParams = AgentLaunchParams;
825
+ const buildReviewLaunch = (engine: ReviewEngine): LaunchParams => {
826
+ // Carry the chosen review only when it is a custom one. Absent → the server
827
+ // resolves to the built-in default.
828
+ const review = effectiveReviewProfileId !== 'builtin:default' ? { reviewProfileId: effectiveReviewProfileId } : {};
829
+ if (engine === 'claude') {
830
+ return { provider: 'claude', label: 'Code Review', model: claudeModel, effort: claudeEffort, ...review };
831
+ }
832
+ if (engine === 'cursor') {
833
+ // Omission ⇒ auto: drop the model client-side when it's `auto` so the POST
834
+ // carries no model and the server lets Cursor pick its default.
835
+ return {
836
+ provider: 'cursor',
837
+ label: 'Code Review',
838
+ ...(cursorModel && cursorModel.toLowerCase() !== 'auto' ? { model: cursorModel } : {}),
839
+ ...review,
840
+ };
841
+ }
842
+ if (engine === 'opencode') {
843
+ // Empty model ⇒ OpenCode's configured default; only send a real model id.
844
+ return {
845
+ provider: 'opencode',
846
+ label: 'Code Review',
847
+ ...(opencodeModel ? { model: opencodeModel } : {}),
848
+ ...review,
849
+ };
850
+ }
851
+ if (engine === 'pi') {
852
+ // Empty model ⇒ Pi's own default; only send a real model id.
853
+ return {
854
+ provider: 'pi',
855
+ label: 'Code Review',
856
+ ...(piModel ? { model: piModel } : {}),
857
+ thinking: piThinking,
858
+ ...review,
859
+ };
860
+ }
861
+ if (engine === 'copilot') {
862
+ // Empty model ⇒ Copilot's own pick; only send a real model id.
863
+ return {
864
+ provider: 'copilot',
865
+ label: 'Code Review',
866
+ ...(copilotModel ? { model: copilotModel } : {}),
867
+ ...review,
868
+ };
869
+ }
870
+ return {
871
+ provider: 'codex',
872
+ label: 'Code Review',
873
+ model: codexModel,
874
+ reasoningEffort: codexReasoning,
875
+ ...(codexFast && { fastMode: true }),
876
+ ...review,
877
+ };
878
+ };
879
+ const buildTourLaunch = (): LaunchParams => ({
880
+ provider: 'tour',
881
+ label: 'Code Tour',
882
+ engine: tourEngine,
883
+ model: tourEngine === 'claude' ? tourClaudeModel : tourCodexModel,
884
+ ...(tourEngine === 'claude'
885
+ ? { effort: tourClaudeEffort }
886
+ : { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }),
887
+ });
888
+ const buildGuideLaunch = (): LaunchParams => {
889
+ if (guideEngine === 'cursor') {
890
+ // Same omission rules as buildReviewLaunch: auto/empty ⇒ engine default.
891
+ // Guide-scoped model — deliberately NOT the shared cursorModel (see
892
+ // guideCursorModel's definition in useAgentSettings).
893
+ return {
894
+ provider: 'guide',
895
+ label: 'Guided Review',
896
+ engine: 'cursor',
897
+ ...(guideCursorModel && guideCursorModel.toLowerCase() !== 'auto' ? { model: guideCursorModel } : {}),
898
+ };
899
+ }
900
+ if (guideEngine === 'opencode') {
901
+ return {
902
+ provider: 'guide',
903
+ label: 'Guided Review',
904
+ engine: 'opencode',
905
+ ...(guideOpencodeModel ? { model: guideOpencodeModel } : {}),
906
+ };
907
+ }
908
+ if (guideEngine === 'pi') {
909
+ return {
910
+ provider: 'guide',
911
+ label: 'Guided Review',
912
+ engine: 'pi',
913
+ ...(guidePiModel ? { model: guidePiModel } : {}),
914
+ thinking: guidePiThinking,
915
+ };
916
+ }
917
+ if (guideEngine === 'copilot') {
918
+ return {
919
+ provider: 'guide',
920
+ label: 'Guided Review',
921
+ engine: 'copilot',
922
+ ...(guideCopilotModel ? { model: guideCopilotModel } : {}),
923
+ };
924
+ }
925
+ return {
926
+ provider: 'guide',
927
+ label: 'Guided Review',
928
+ engine: guideEngine,
929
+ model: guideEngine === 'claude' ? guideClaudeModel : guideCodexModel,
930
+ ...(guideEngine === 'claude'
931
+ ? { effort: guideClaudeEffort }
932
+ : { reasoningEffort: guideCodexReasoning }),
933
+ };
934
+ };
935
+
936
+ // For a custom pick, hold launch until the profile list has loaded — otherwise
937
+ // the saved id can't be found yet and the launch would quietly run Default. A
938
+ // Default pick has nothing to resolve, so it never waits.
939
+ const reviewReady = profilesLoaded || reviewProfileId === 'builtin:default';
940
+ const canLaunch = selectedMode === 'review'
941
+ ? reviewEngineAvailable(reviewEngine) && reviewReady
942
+ : selectedMode === 'tour'
943
+ ? tourAvailable && engineAvailable(tourEngine)
944
+ : selectedMode === 'guide'
945
+ ? guideAvailable && reviewEngineAvailable(guideEngine) && guideLaunchable
946
+ : false;
947
+
948
+ const handleLaunch = async () => {
949
+ if (!canLaunch || launchingRef.current) return;
950
+ const params = selectedMode === 'review'
951
+ ? buildReviewLaunch(reviewEngine)
952
+ : selectedMode === 'tour'
953
+ ? buildTourLaunch()
954
+ : buildGuideLaunch();
955
+ launchingRef.current = true;
956
+ setPendingLaunch({
957
+ label: params.label ?? 'Agent job',
958
+ ...(params.provider && { provider: params.provider }),
959
+ startedAt: Date.now(),
960
+ });
961
+ setLaunchError(null);
962
+
963
+ try {
964
+ const result = await onLaunch(params);
965
+ if (result === null) {
966
+ setLaunchError('Could not start agent job.');
967
+ }
968
+ } catch (error) {
969
+ setLaunchError(error instanceof Error ? error.message : 'Could not start agent job.');
970
+ } finally {
971
+ launchingRef.current = false;
972
+ setPendingLaunch(null);
973
+ }
974
+ };
975
+
976
+ const modeOptions = availableModes.map((mode) => ({ value: mode, label: MODE_LABEL[mode] }));
977
+ const renderStaticChoice = (label: string, icon?: React.ReactNode) => (
978
+ <div className="flex items-center gap-2 rounded-lg border border-border/30 bg-surface-1/30 px-2.5 py-1.5">
979
+ {icon}
980
+ <span className="text-[11px] text-foreground/80">{label}</span>
981
+ </div>
982
+ );
983
+
984
+ // Icon-button engine row, shared by Tour (narrow claude/codex set) and Review
985
+ // (wide claude/codex/cursor/opencode set). The caller passes the engine list
986
+ // plus its icon/label maps so the same control renders four equal options for
987
+ // Review exactly as it renders two for Tour.
988
+ function renderEngineSelect<E extends string>(
989
+ value: E,
990
+ onChange: (engine: E) => void,
991
+ engines: E[],
992
+ iconMap: Record<E, React.FC<{ className?: string }>>,
993
+ labelMap: Record<E, string>,
994
+ configLabel: string = 'Engine',
995
+ ) {
996
+ const StaticIcon: React.FC<{ className?: string }> = iconMap[value];
997
+ return (
998
+ <ConfigRow label={configLabel} stacked>
999
+ {engines.length > 1 ? (
1000
+ // Tap an agent's mark to pick it — no dropdown.
1001
+ <div className="flex items-center gap-1.5">
1002
+ {engines.map((engine) => {
1003
+ const Icon: React.FC<{ className?: string }> = iconMap[engine];
1004
+ const selected = value === engine;
1005
+ return (
1006
+ <button
1007
+ key={engine}
1008
+ type="button"
1009
+ onClick={() => onChange(engine)}
1010
+ title={labelMap[engine]}
1011
+ aria-label={labelMap[engine]}
1012
+ aria-pressed={selected}
1013
+ className={cn(
1014
+ 'flex h-9 w-9 items-center justify-center rounded-lg border transition-all',
1015
+ selected
1016
+ ? 'border-primary/40 bg-primary/5'
1017
+ : 'border-border/30 bg-surface-1/30 opacity-40 hover:opacity-100',
1018
+ )}
1019
+ >
1020
+ <Icon className="h-5 w-5" />
1021
+ </button>
1022
+ );
1023
+ })}
1024
+ </div>
1025
+ ) : (
1026
+ renderStaticChoice(labelMap[value], <StaticIcon className="h-4 w-4" />)
1027
+ )}
1028
+ </ConfigRow>
1029
+ );
1030
+ }
1031
+
1032
+ // Cursor and OpenCode share the same review config: an "experimental" note and
1033
+ // a single model picker driven by their live (or fallback) catalog.
1034
+ const renderMarkerEngineConfig = (
1035
+ model: string,
1036
+ models: Array<{ value: string; label: string }>,
1037
+ setModel: (value: string) => void,
1038
+ ) => (
1039
+ <>
1040
+ <div className="flex items-center gap-1.5 text-[10px] text-amber-600 dark:text-amber-400">
1041
+ <span className="rounded bg-amber-500/10 px-1 py-px font-medium">experimental</span>
1042
+ <span className="text-muted-foreground/50">Findings are prompt-enforced</span>
1043
+ </div>
1044
+ <ConfigRow label="Model" stacked>
1045
+ {models.length > 1 ? (
1046
+ <SelectMenu value={model} options={models} onChange={setModel} />
1047
+ ) : (
1048
+ renderStaticChoice(catalogLabel(models, model))
1049
+ )}
1050
+ </ConfigRow>
1051
+ </>
1052
+ );
1053
+
1054
+ return (
1055
+ <div className="flex flex-col h-full">
1056
+ {/* Launch panel (pinned to the top) */}
1057
+ {availableModes.length > 0 && (
1058
+ <div className="border-b border-border/40 p-3">
1059
+ <div className="mb-2 font-medium text-[9px] uppercase tracking-wider text-muted-foreground/40">
1060
+ Launch agent
1061
+ </div>
1062
+
1063
+ <div className="space-y-2">
1064
+ {availableModes.length > 1 ? (
1065
+ <SelectMenu
1066
+ value={selectedMode ?? ''}
1067
+ options={modeOptions}
1068
+ onChange={(next) => setSelectedMode(next as AgentMode)}
1069
+ icon={<Bot className="shrink-0 text-muted-foreground/50" size={12} />}
1070
+ placeholder="Select mode"
1071
+ />
1072
+ ) : (
1073
+ renderStaticChoice(
1074
+ availableModes[0] ? MODE_LABEL[availableModes[0]] : '',
1075
+ <Bot className="shrink-0 text-muted-foreground/50" size={12} />,
1076
+ )
1077
+ )}
1078
+
1079
+ {selectedMode === 'review' && (
1080
+ <>
1081
+ {/* Provider (engine) picker — icon-button row over the wide set
1082
+ (claude/codex/cursor/opencode), above the review selector. */}
1083
+ {renderEngineSelect(
1084
+ reviewEngine,
1085
+ setReviewEngine,
1086
+ availableReviewEngines,
1087
+ REVIEW_ENGINE_ICON,
1088
+ REVIEW_ENGINE_LABEL,
1089
+ 'Provider',
1090
+ )}
1091
+ <ConfigRow label="Review" stacked>
1092
+ <SelectMenu
1093
+ value={effectiveReviewProfileId}
1094
+ options={reviewProfiles.map((p) => ({ value: p.id, label: p.label }))}
1095
+ onChange={setReviewProfileId}
1096
+ footerAction={{ label: 'Add new review', onClick: () => setAddReviewOpen(true) }}
1097
+ />
1098
+ </ConfigRow>
1099
+ {reviewEngine === 'claude' && (
1100
+ <>
1101
+ <ConfigRow label="Model" stacked>
1102
+ <SelectMenu value={claudeModel} options={CLAUDE_MODELS} onChange={setClaudeModel} />
1103
+ </ConfigRow>
1104
+ <ConfigRow label="Effort" stacked>
1105
+ <SegmentedPicker options={CLAUDE_EFFORT} value={claudeEffort} onChange={setClaudeEffort} />
1106
+ </ConfigRow>
1107
+ </>
1108
+ )}
1109
+ {reviewEngine === 'codex' && (
1110
+ <>
1111
+ <ConfigRow label="Model" stacked>
1112
+ <SelectMenu value={codexModel} options={CODEX_MODELS} onChange={setCodexModel} />
1113
+ </ConfigRow>
1114
+ <ConfigRow label="Reasoning" stacked>
1115
+ <SegmentedPicker options={CODEX_REASONING} value={codexReasoning} onChange={setCodexReasoning} />
1116
+ </ConfigRow>
1117
+ <ConfigRow label="Fast mode">
1118
+ <Toggle checked={codexFast} onChange={setCodexFast} />
1119
+ </ConfigRow>
1120
+ </>
1121
+ )}
1122
+ {reviewEngine === 'cursor' && renderMarkerEngineConfig(cursorModel, cursorModels, setCursorModel)}
1123
+ {reviewEngine === 'opencode' && renderMarkerEngineConfig(opencodeModel, opencodeModels, setOpencodeModel)}
1124
+ {reviewEngine === 'pi' && (
1125
+ <>
1126
+ {renderMarkerEngineConfig(piModel, piModels, setPiModel)}
1127
+ <ConfigRow label="Thinking" stacked>
1128
+ <SegmentedPicker options={PI_THINKING} value={piThinking} onChange={setPiThinking} />
1129
+ </ConfigRow>
1130
+ </>
1131
+ )}
1132
+ {reviewEngine === 'copilot' && renderMarkerEngineConfig(copilotModel, copilotModels, setCopilotModel)}
1133
+ </>
1134
+ )}
1135
+
1136
+ {selectedMode === 'tour' && (
1137
+ <>
1138
+ {renderEngineSelect(tourEngine, setTourEngine, availableEngines, ENGINE_ICON, ENGINE_LABEL)}
1139
+ <ConfigRow label="Model" stacked>
1140
+ <SelectMenu
1141
+ value={tourEngine === 'claude' ? tourClaudeModel : tourCodexModel}
1142
+ options={tourEngine === 'claude' ? TOUR_CLAUDE_MODELS : CODEX_MODELS}
1143
+ onChange={tourEngine === 'claude' ? setTourClaudeModel : setTourCodexModel}
1144
+ />
1145
+ </ConfigRow>
1146
+
1147
+ {/* Claude-only: effort level */}
1148
+ {tourEngine === 'claude' && (
1149
+ <ConfigRow label="Effort" stacked>
1150
+ <SegmentedPicker options={CLAUDE_EFFORT} value={tourClaudeEffort} onChange={setTourClaudeEffort} />
1151
+ </ConfigRow>
1152
+ )}
1153
+
1154
+ {/* Codex-only: reasoning effort + fast mode */}
1155
+ {tourEngine === 'codex' && (
1156
+ <>
1157
+ <ConfigRow label="Reasoning" stacked>
1158
+ <SegmentedPicker options={CODEX_REASONING} value={tourCodexReasoning} onChange={setTourCodexReasoning} />
1159
+ </ConfigRow>
1160
+ <ConfigRow label="Fast mode">
1161
+ <Toggle checked={tourCodexFast} onChange={setTourCodexFast} />
1162
+ </ConfigRow>
1163
+ </>
1164
+ )}
1165
+ </>
1166
+ )}
1167
+
1168
+ {selectedMode === 'guide' && (
1169
+ <>
1170
+ {renderEngineSelect(guideEngine, setGuideEngine, availableReviewEngines, REVIEW_ENGINE_ICON, REVIEW_ENGINE_LABEL)}
1171
+ {(guideEngine === 'claude' || guideEngine === 'codex') && (
1172
+ <ConfigRow label="Model" stacked>
1173
+ <SelectMenu
1174
+ value={guideEngine === 'claude' ? guideClaudeModel : guideCodexModel}
1175
+ options={guideEngine === 'claude' ? TOUR_CLAUDE_MODELS : CODEX_MODELS}
1176
+ onChange={guideEngine === 'claude' ? setGuideClaudeModel : setGuideCodexModel}
1177
+ />
1178
+ </ConfigRow>
1179
+ )}
1180
+
1181
+ {/* Claude-only: effort level */}
1182
+ {guideEngine === 'claude' && (
1183
+ <ConfigRow label="Effort" stacked>
1184
+ <SegmentedPicker options={CLAUDE_EFFORT} value={guideClaudeEffort} onChange={setGuideClaudeEffort} />
1185
+ </ConfigRow>
1186
+ )}
1187
+
1188
+ {/* Codex-only: reasoning effort. No "Fast mode" toggle here
1189
+ (unlike review/tour's codex blocks above) — fast mode is
1190
+ deliberately not offered for guide. */}
1191
+ {guideEngine === 'codex' && (
1192
+ <ConfigRow label="Reasoning" stacked>
1193
+ <SegmentedPicker options={CODEX_REASONING} value={guideCodexReasoning} onChange={setGuideCodexReasoning} />
1194
+ </ConfigRow>
1195
+ )}
1196
+
1197
+ {/* Marker engines: same live-catalog model picker as review mode,
1198
+ but bound to the guide-scoped settings (see useAgentSettings) so
1199
+ tuning these doesn't change the next Cursor/OpenCode/Pi review. */}
1200
+ {guideEngine === 'cursor' && renderMarkerEngineConfig(guideCursorModel, cursorModels, setGuideCursorModel)}
1201
+ {guideEngine === 'opencode' && renderMarkerEngineConfig(guideOpencodeModel, opencodeModels, setGuideOpencodeModel)}
1202
+ {guideEngine === 'pi' && (
1203
+ <>
1204
+ {renderMarkerEngineConfig(guidePiModel, piModels, setGuidePiModel)}
1205
+ <ConfigRow label="Thinking" stacked>
1206
+ <SegmentedPicker options={PI_THINKING} value={guidePiThinking} onChange={setGuidePiThinking} />
1207
+ </ConfigRow>
1208
+ </>
1209
+ )}
1210
+ {guideEngine === 'copilot' && renderMarkerEngineConfig(guideCopilotModel, copilotModels, setGuideCopilotModel)}
1211
+ </>
1212
+ )}
1213
+ </div>
1214
+
1215
+ <button
1216
+ onClick={handleLaunch}
1217
+ disabled={!canLaunch || pendingLaunch !== null}
1218
+ aria-busy={pendingLaunch !== null}
1219
+ className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg bg-primary py-2 font-medium text-[12px] text-primary-foreground transition-colors hover:bg-primary/90 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
1220
+ >
1221
+ {pendingLaunch ? <Loader2 className="animate-spin" size={11} /> : <Play size={11} />}
1222
+ {pendingLaunch ? 'Starting...' : 'Run'}
1223
+ </button>
1224
+ {launchError && (
1225
+ <p className="mt-2 text-[10px] leading-snug text-destructive/80">
1226
+ {launchError}
1227
+ </p>
1228
+ )}
1229
+ </div>
1230
+ )}
1231
+
1232
+ {/* Job list (scrolls; launch controls are pinned above) */}
1233
+ <div className="flex-1 overflow-y-auto p-2 space-y-1">
1234
+ {pendingLaunch && (
1235
+ <PendingLaunchCard
1236
+ label={pendingLaunch.label}
1237
+ provider={pendingLaunch.provider}
1238
+ startedAt={pendingLaunch.startedAt}
1239
+ />
1240
+ )}
1241
+ {sortedJobs.length === 0 && !pendingLaunch ? (
1242
+ <div className="flex flex-col items-center py-10 text-center">
1243
+ <div className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-surface-1/50">
1244
+ <ReviewAgentsIcon className="h-4 w-4 text-muted-foreground/40" />
1245
+ </div>
1246
+ <p className="text-[11px] text-muted-foreground/40">No agent jobs</p>
1247
+ <p className="mt-0.5 text-[10px] text-muted-foreground/35">Launch an agent above</p>
1248
+ </div>
1249
+ ) : (
1250
+ sortedJobs.map((job) => (
1251
+ <JobCard
1252
+ key={job.id}
1253
+ job={job}
1254
+ annotationCount={annotationCounts.get(job.source) ?? 0}
1255
+ onKill={() => onKillJob(job.id)}
1256
+ expanded={expandedJobId === job.id}
1257
+ onToggle={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
1258
+ onViewDetails={onOpenJobDetail ? () => onOpenJobDetail(job.id) : undefined}
1259
+ onOpenGuide={onOpenGuide && (canOpenGuideJob?.(job) ?? true) ? () => onOpenGuide(job.id) : undefined}
1260
+ />
1261
+ ))
1262
+ )}
1263
+ </div>
1264
+
1265
+ {/* Kill all — pinned at the bottom */}
1266
+ {runningCount >= 2 && (
1267
+ <div className="px-3 pb-2 pt-1">
1268
+ <button
1269
+ onClick={onKillAll}
1270
+ className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 py-1.5 font-medium text-[10px] text-red-600 transition-colors hover:bg-red-500/10 dark:text-red-400"
1271
+ >
1272
+ <Square size={8} />
1273
+ Kill all ({runningCount})
1274
+ </button>
1275
+ </div>
1276
+ )}
1277
+
1278
+ {addReviewOpen && (
1279
+ <AddReviewDialog
1280
+ onClose={() => setAddReviewOpen(false)}
1281
+ onEnabled={(name) => {
1282
+ const id = `skill:${name}`;
1283
+ // Add optimistically so the dropdown can select it immediately; the
1284
+ // refresh below reconciles against the server.
1285
+ setReviewProfiles((prev) => (prev.some((p) => p.id === id) ? prev : [...prev, { id, label: name }]));
1286
+ setReviewProfileId(id);
1287
+ setAddReviewOpen(false);
1288
+ refreshReviewProfiles();
1289
+ }}
1290
+ />
1291
+ )}
1292
+ </div>
1293
+ );
1294
+ };