import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Bot, Play, X, Square, Loader2, CheckCircle2, AlertTriangle, Skull, ExternalLink, Zap, Plus, Search, } from 'lucide-react'; import type { AgentJobInfo, AgentCapabilities } from '../types'; import { isTerminalStatus } from '@plannotator/core/agent-jobs'; import { GUIDE_ENGINE_LABEL } from '@plannotator/core/guide'; import { cn } from '../lib/utils'; import { ReviewAgentsIcon } from './ReviewAgentsIcon'; import { ClaudeIcon, CodexIcon, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons'; import { useAgentSettings } from '../hooks/useAgentSettings'; import type { AgentEngine, AgentMode, ReviewEngine } from '../hooks/useAgentSettings'; import type { AgentLaunchParams } from '../hooks/useAgentJobs'; import { ConfigRow, SegmentedPicker, Toggle, SelectMenu } from './AgentControls'; import { ModelSourceHint } from './ModelSourceHint'; import { CLAUDE_FALLBACK_MODELS, CODEX_FALLBACK_MODELS, EFFORT_LABELS, effortSelectOptions, modelLabel, modelSelectOptions, PI_THINKING_LEVELS, type CatalogModel, } from '@plannotator/core/model-catalog'; import { useModelCatalogs, type ModelCatalogs } from '../hooks/useModelCatalogs'; export type { AgentLaunchParams } from '../hooks/useAgentJobs'; // --- Agent option catalogs --- // Claude and Codex models come from the installed CLIs (useModelCatalogs — // the same lists Ask AI shows); only the marker-engine fallbacks live here. /** Whether a model offers a fast tier (Codex's fast mode toggle). */ export function modelSupportsFast(models: readonly CatalogModel[], modelId: string): boolean { return models.find((m) => m.id === modelId)?.fastMode === true; } // Fallback Cursor model catalog (just `auto`). The real, account-specific list // is discovered server-side via `agent models` and delivered on the cursor // capability; the component prefers that and only falls back to this when the // server reports no models (e.g. unauthenticated CLI). Used by formatModel for // job-card labels where the live list isn't threaded. const CURSOR_MODELS: Array<{ value: string; label: string }> = [ { value: 'auto', label: 'Auto' }, ]; // Fallback OpenCode model catalog. The real list is discovered server-side via // `opencode models` and delivered on the opencode capability; empty value means // "use OpenCode's configured default". const OPENCODE_MODELS: Array<{ value: string; label: string }> = [ { value: '', label: 'Default' }, ]; // Fallback Pi model catalog. The real list is discovered server-side and // delivered on the pi capability; empty value means "use Pi's own default". const PI_MODELS: Array<{ value: string; label: string }> = [ { value: '', label: 'Default' }, ]; // Fallback Copilot model catalog. The real list is discovered server-side via // `copilot help config`; empty value means "let Copilot pick". const COPILOT_MODELS: Array<{ value: string; label: string }> = [ { value: '', label: 'Default' }, ]; // Pi's unified reasoning knob (`--thinking`), applied to whichever model is // selected; pi clamps a level the model does not support. Pi's own level // vocabulary minus `max`, which pi only accepts on `--thinking` since 0.80.6 // (an older pi would reject the whole job). Short labels fit the segmented // picker. const PI_THINKING_SHORT_LABELS: Record = { minimal: 'Min', medium: 'Med' }; export const PI_THINKING: Array<{ value: string; label: string }> = PI_THINKING_LEVELS .filter((level) => level !== 'max') .map((level) => ({ value: level, label: PI_THINKING_SHORT_LABELS[level] ?? EFFORT_LABELS[level] ?? level })); const MODE_LABEL: Record = { review: 'Code Review', tour: 'Code Tour', guide: 'Guided Review', }; const ENGINE_LABEL: Record = { claude: 'Claude', codex: 'Codex', }; const ENGINE_ICON: Record> = { claude: ClaudeIcon, codex: CodexIcon, }; // Review-only label map. Keeps Tour's narrow AgentEngine maps valid while the // review surface offers the wider set (Cursor/OpenCode). Exported so the guide // takeover surfaces (GuideScreen, GuideEmptyState in packages/review-editor) // share this one source of truth instead of keeping their own copies in sync. export const REVIEW_ENGINE_LABEL: Record = GUIDE_ENGINE_LABEL; // Review-only icon map — the wide set. Tour keeps the narrow ENGINE_ICON. const REVIEW_ENGINE_ICON: Record> = { claude: ClaudeIcon, codex: CodexIcon, cursor: CursorIcon, opencode: OpenCodeIcon, pi: PiIcon, copilot: CopilotIcon, }; export type AgentLaunchResult = AgentJobInfo | null | void; interface AgentsTabProps { jobs: AgentJobInfo[]; capabilities: AgentCapabilities | null; onLaunch: (params: AgentLaunchParams) => AgentLaunchResult | Promise; onKillJob: (id: string) => void; onKillAll: () => void; externalAnnotations: Array<{ source?: string }>; onOpenJobDetail?: (jobId: string) => void; onOpenGuide?: (jobId: string) => void; /** Whether the current diff has any files a guide could reference — mirrors * the review-editor header's `hasSearchableFiles` gate (the "Guide" badge * and its keyboard shortcut). A guide organizes changed files into * chapters, so with none available there is nothing for it to do; default * true so callers that don't pass it (e.g. the plan editor, which has no * concept of "files") see unchanged behavior. */ guideLaunchable?: boolean; /** Whether a given guide job's artifact can be opened from HERE — i.e. it * belongs to the review context currently on screen. The job list spans * every context visited this session, but opening only sets * activeGuideJobId/guideOpen (it does NOT switch PRs), so a cross-context * "Open guide" would land on the wrong guide or the empty state. Default * undefined ⇒ always openable (non-review callers have no contexts). */ canOpenGuideJob?: (job: AgentJobInfo) => boolean; } // --- Duration display --- // Exported so other agent-job surfaces (e.g. GuideGenerating in // review-editor) share this one implementation instead of keeping their own // copies in sync. export function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } export function ElapsedTime({ startedAt }: { startedAt: number }) { const [, setTick] = useState(0); useEffect(() => { const timer = setInterval(() => setTick((t) => t + 1), 1000); return () => clearInterval(timer); }, []); return <>{formatDuration(Date.now() - startedAt)}; } // --- Status square (colored tile + lucide glyph, matches the prototype) --- const JOB_STATUS_BG: Record = { starting: 'bg-muted-foreground/10', running: 'bg-primary/10', done: 'bg-green-500/10', failed: 'bg-red-500/10', killed: 'bg-orange-500/10', }; const JOB_STATUS_ICON: Record = { starting: , running: , done: , failed: , killed: , }; function StatusSquare({ status }: { status: AgentJobInfo['status'] }) { return (
{JOB_STATUS_ICON[status]}
); } // --- Provider badge --- // Lookup a human label from the catalogs; fall back to the raw id. function catalogLabel(list: Array<{ value: string; label: string }>, value: string): string { return list.find((o) => o.value === value)?.label ?? value; } type ModelLabelCatalogs = Pick; function formatModel(provider: string, engine: string | undefined, model: string, catalogs: ModelLabelCatalogs): string { // Claude/Codex: the discovered catalog's label (fallback list, then alias // naming, then the raw id for anything neither knows). const claudeLabel = () => modelLabel([...catalogs.claude.models, ...CLAUDE_FALLBACK_MODELS], model); const codexLabel = () => modelLabel([...catalogs.codex.models, ...CODEX_FALLBACK_MODELS], model); if (provider === 'cursor') return catalogLabel(CURSOR_MODELS, model); if (provider === 'opencode') return model ? model : 'Default'; if (provider === 'pi') return model || 'Default'; if (provider === 'copilot') return model || 'Default'; if (provider === 'codex' || engine === 'codex') return codexLabel(); if ((provider === 'tour' || provider === 'guide') && engine === 'claude') return claudeLabel(); if (provider === 'tour' || provider === 'guide') { if (engine === 'cursor') return catalogLabel(CURSOR_MODELS, model); if (engine === 'opencode' || engine === 'pi' || engine === 'copilot') return model || 'Default'; } return claudeLabel(); } function formatThinking(value: string): string { return catalogLabel(PI_THINKING, value); } function formatEffort(value: string): string { return EFFORT_LABELS[value] ?? value; } // --- Add-a-review dialog: a type-ahead picker over every discovered skill --- interface CatalogSkill { name: string; root: string; sourcePath: string; enabled: boolean; } function AddReviewDialog({ onClose, onEnabled, }: { onClose: () => void; onEnabled: (name: string) => void; }) { const [skills, setSkills] = useState(null); const [query, setQuery] = useState(''); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); useEffect(() => { let alive = true; fetch('/api/agents/skills') .then((r) => r.json()) .then((d) => { if (alive) setSkills(Array.isArray(d.skills) ? d.skills : []); }) .catch(() => { if (alive) setSkills([]); }); return () => { alive = false; }; }, []); const candidates = useMemo(() => { const q = query.trim().toLowerCase(); return (skills ?? []) .filter((s) => !s.enabled) .filter((s) => (q ? s.name.toLowerCase().includes(q) : true)); }, [skills, query]); const enable = async (name: string) => { setBusy(name); setError(null); try { const res = await fetch('/api/agents/review-skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error ?? 'Could not add review.'); } onEnabled(name); } catch (e) { setError(e instanceof Error ? e.message : 'Could not add review.'); setBusy(null); } }; return (
Add a review
setQuery(e.target.value)} placeholder="Filter your skills" className="min-w-0 flex-1 bg-transparent text-[12px] text-foreground/90 outline-none placeholder:text-muted-foreground/40" />
{skills === null ? (
) : candidates.length === 0 ? (

{query ? 'No matching skills.' : 'No skills left to add.'}

) : ( candidates.map((s) => ( )) )}
{error &&

{error}

}
); } // --- Job card --- function JobCard({ job, annotationCount, onKill, expanded, onToggle, onViewDetails, onOpenGuide, catalogs, }: { job: AgentJobInfo; catalogs: ModelLabelCatalogs; annotationCount: number; onKill: () => void; expanded: boolean; onToggle: () => void; onViewDetails?: () => void; onOpenGuide?: () => void; }) { const isTerminal = isTerminalStatus(job.status); return (
onViewDetails() : (isTerminal ? onToggle : undefined)} >
{job.label} {onViewDetails && }
{job.provider} {job.model && ( {formatModel(job.provider, job.engine, job.model, catalogs)} )} {job.effort && {formatEffort(job.effort)}} {job.reasoningEffort && {formatEffort(job.reasoningEffort)}} {job.thinking && {formatThinking(job.thinking)}} {job.fastMode && ( fast )} · {isTerminal && job.endedAt ? formatDuration(job.endedAt - job.startedAt) : } {annotationCount > 0 && ( <> · {annotationCount} finding{annotationCount !== 1 ? 's' : ''} )}
{!isTerminal && ( )} {job.warning && (

{job.warning}

)} {/* Error details — fallback for when the dockview detail panel is not available */} {!onViewDetails && job.status === 'failed' && job.error && expanded && (
            {job.error}
          
)} {/* Open guide — a completed guide job's direct affordance into the takeover. */} {job.provider === 'guide' && job.status === 'done' && onOpenGuide && ( )}
); } function PendingLaunchCard({ label, provider, startedAt, }: { label: string; provider?: string; startedAt: number; }) { return (
{label}
{provider && {provider}} requesting launch ·
); } // --- Main component --- export const AgentsTab: React.FC = ({ jobs, capabilities, onLaunch, onKillJob, onKillAll, externalAnnotations, onOpenJobDetail, onOpenGuide, guideLaunchable = true, canOpenGuideJob, }) => { const [expandedJobId, setExpandedJobId] = useState(null); const [pendingLaunch, setPendingLaunch] = useState<{ label: string; provider?: string; startedAt: number } | null>(null); const [launchError, setLaunchError] = useState(null); const launchingRef = useRef(false); // Claude/Codex catalogs are shared with Ask AI; only the engine this panel // is set to is fetched (never on page load), and saved picks resolve // against it. const catalogs = useModelCatalogs(capabilities); const settings = useAgentSettings(catalogs); const { selectedMode, reviewEngine, reviewProfileId, tourEngine, guideEngine, claudeModel, claudeEffort, codexModel, codexReasoning, codexFast, cursorModel, opencodeModel, piModel, piThinking, copilotModel, tourClaudeModel, tourClaudeEffort, tourCodexModel, tourCodexReasoning, tourCodexFast, guideClaudeModel, guideClaudeEffort, guideCodexModel, guideCodexReasoning, guideCursorModel, guideOpencodeModel, guidePiModel, guidePiThinking, guideCopilotModel, setSelectedMode, setReviewEngine, setReviewProfileId, setTourEngine, setGuideEngine, setClaudeModel, setClaudeEffort, setCodexModel, setCodexReasoning, setCodexFast, setCursorModel, setOpencodeModel, setPiModel, setPiThinking, setCopilotModel, setTourClaudeModel, setTourClaudeEffort, setTourCodexModel, setTourCodexReasoning, setTourCodexFast, setGuideClaudeModel, setGuideClaudeEffort, setGuideCodexModel, setGuideCodexReasoning, setGuideCursorModel, setGuideOpencodeModel, setGuidePiModel, setGuidePiThinking, setGuideCopilotModel, } = settings; const activeEngine = selectedMode === 'review' ? reviewEngine : selectedMode === 'tour' ? tourEngine : guideEngine; const loadCatalog = catalogs.load; useEffect(() => loadCatalog(activeEngine), [loadCatalog, activeEngine]); // Review profiles (built-in default plus the user's enabled skills). Loaded // from the discovery endpoint and refreshed after a skill is added. const [reviewProfiles, setReviewProfiles] = useState>([ { id: 'builtin:default', label: 'Default', default: true }, ]); // Until the list has loaded we can't tell a saved custom pick from a removed // one, so a launch in that window would silently fall back to Default. Gate // launch on this for a custom pick (see canLaunch). const [profilesLoaded, setProfilesLoaded] = useState(false); const [addReviewOpen, setAddReviewOpen] = useState(false); const refreshReviewProfiles = useCallback(() => { fetch('/api/agents/review-profiles') .then((r) => r.json()) .then((d) => { if (Array.isArray(d.profiles) && d.profiles.length > 0) setReviewProfiles(d.profiles); }) .catch(() => {}) .finally(() => setProfilesLoaded(true)); }, []); useEffect(() => { refreshReviewProfiles(); }, [refreshReviewProfiles]); const claudeAvailable = capabilities?.providers.some((p) => p.id === 'claude' && p.available) ?? false; const codexAvailable = capabilities?.providers.some((p) => p.id === 'codex' && p.available) ?? false; const tourAvailable = capabilities?.providers.some((p) => p.id === 'tour' && p.available) ?? false; const guideAvailable = capabilities?.providers.some((p) => p.id === 'guide' && p.available) ?? false; const cursorAvailable = capabilities?.providers.some((p) => p.id === 'cursor' && p.available) ?? false; const opencodeAvailable = capabilities?.providers.some((p) => p.id === 'opencode' && p.available) ?? false; const piAvailable = capabilities?.providers.some((p) => p.id === 'pi' && p.available) ?? false; const copilotAvailable = capabilities?.providers.some((p) => p.id === 'copilot' && p.available) ?? false; // Cursor's model catalog is account-specific and discovered server-side, so // prefer the live list from the capability; fall back to `auto`-only when the // server reports none (e.g. unauthenticated CLI). const cursorModels = useMemo>(() => { const discovered = capabilities?.providers.find((p) => p.id === 'cursor')?.models ?? []; const opts = discovered.map((m) => ({ value: m.id, label: m.label })); return opts.length > 0 ? opts : CURSOR_MODELS; }, [capabilities]); // OpenCode models discovered server-side via `opencode models`; prepend the // "Default" option so the user can leave the model to OpenCode's config. const opencodeModels = useMemo>(() => { const discovered = capabilities?.providers.find((p) => p.id === 'opencode')?.models ?? []; const opts = discovered.map((m) => ({ value: m.id, label: m.label })); return opts.length > 0 ? [...OPENCODE_MODELS, ...opts] : OPENCODE_MODELS; }, [capabilities]); // Pi models discovered server-side; prepend the "Default" option so the user // can leave the model to Pi's own default (same convention as OpenCode). const piModels = useMemo>(() => { const discovered = capabilities?.providers.find((p) => p.id === 'pi')?.models ?? []; const opts = discovered.map((m) => ({ value: m.id, label: m.label })); return opts.length > 0 ? [...PI_MODELS, ...opts] : PI_MODELS; }, [capabilities]); // Copilot models discovered server-side via `copilot help config`; prepend // the "Default" option so the user can leave the model to Copilot's own pick. const copilotModels = useMemo>(() => { const discovered = capabilities?.providers.find((p) => p.id === 'copilot')?.models ?? []; const opts = discovered.map((m) => ({ value: m.id, label: m.label })); return opts.length > 0 ? [...COPILOT_MODELS, ...opts] : COPILOT_MODELS; }, [capabilities]); // Tour engines (narrow union). Cursor is NOT included here — it is review-only. const availableEngines = useMemo(() => { const engines: AgentEngine[] = []; if (claudeAvailable) engines.push('claude'); if (codexAvailable) engines.push('codex'); return engines; }, [claudeAvailable, codexAvailable]); // Review engines (wide union) = tour engines + cursor/opencode when available. const availableReviewEngines = useMemo(() => { const engines: ReviewEngine[] = [...availableEngines]; if (cursorAvailable) engines.push('cursor'); if (opencodeAvailable) engines.push('opencode'); if (piAvailable) engines.push('pi'); if (copilotAvailable) engines.push('copilot'); return engines; }, [availableEngines, cursorAvailable, opencodeAvailable, piAvailable, copilotAvailable]); const availableModes = useMemo(() => { const modes: AgentMode[] = []; if (availableReviewEngines.length > 0) modes.push('review'); if (tourAvailable && availableEngines.length > 0) modes.push('tour'); // Guide runs on the wide union — marker engines generate guides too. // Also gated on guideLaunchable: a guide organizes changed files into // chapters, so it has nothing to do against a diff with no files (same // gate the review-editor header applies to the "Guide" badge/shortcut). if (guideAvailable && availableReviewEngines.length > 0 && guideLaunchable) modes.push('guide'); return modes; }, [availableReviewEngines.length, availableEngines.length, tourAvailable, guideAvailable, guideLaunchable]); // (availableReviewEngines.length covers the guide gate above.) const firstAvailableEngine = availableEngines[0] ?? null; const firstAvailableReviewEngine = availableReviewEngines[0] ?? null; const engineAvailable = (engine: AgentEngine) => engine === 'claude' ? claudeAvailable : codexAvailable; const reviewEngineAvailable = (engine: ReviewEngine) => engine === 'cursor' ? cursorAvailable : engine === 'opencode' ? opencodeAvailable : engine === 'pi' ? piAvailable : engine === 'copilot' ? copilotAvailable : engineAvailable(engine); // Reconcile mode + engine choices against live capabilities. Runs when // capabilities change or the stored selection becomes invalid. useEffect(() => { if (!capabilities || availableModes.length === 0) return; if (!selectedMode || !availableModes.includes(selectedMode)) { setSelectedMode(availableModes[0]); } if (firstAvailableReviewEngine && !reviewEngineAvailable(reviewEngine)) { setReviewEngine(firstAvailableReviewEngine); } if (firstAvailableEngine && !engineAvailable(tourEngine)) { setTourEngine(firstAvailableEngine); } if (firstAvailableReviewEngine && !reviewEngineAvailable(guideEngine)) { setGuideEngine(firstAvailableReviewEngine); } }, [ capabilities, availableModes, firstAvailableEngine, firstAvailableReviewEngine, selectedMode, reviewEngine, tourEngine, guideEngine, setSelectedMode, setReviewEngine, setTourEngine, setGuideEngine, ]); // Reconcile the saved Cursor/OpenCode model against the live catalog: a // persisted id can go stale after an account switch or discovery loss, and // posting it would fail the launch. Collapse it to the first option (auto/ // Default) when it's no longer offered. Each effect also reconciles the // guide-scoped counterpart against the SAME catalog and availability guard // — the catalog is per-engine, not per-surface, so review and guide share // it here even though their model selections are kept independent. useEffect(() => { // Only once the engine is actually available — before capabilities load, // cursorModels is just the fallback, and reconciling here would wipe a valid // saved model before the live catalog arrives. if (!cursorAvailable) return; if (!cursorModels.some((m) => m.value === cursorModel)) { setCursorModel(cursorModels[0]?.value ?? 'auto'); } if (!cursorModels.some((m) => m.value === guideCursorModel)) { setGuideCursorModel(cursorModels[0]?.value ?? 'auto'); } }, [cursorAvailable, cursorModels, cursorModel, setCursorModel, guideCursorModel, setGuideCursorModel]); useEffect(() => { if (!opencodeAvailable) return; if (!opencodeModels.some((m) => m.value === opencodeModel)) { setOpencodeModel(opencodeModels[0]?.value ?? ''); } if (!opencodeModels.some((m) => m.value === guideOpencodeModel)) { setGuideOpencodeModel(opencodeModels[0]?.value ?? ''); } }, [opencodeAvailable, opencodeModels, opencodeModel, setOpencodeModel, guideOpencodeModel, setGuideOpencodeModel]); useEffect(() => { if (!piAvailable) return; if (!piModels.some((m) => m.value === piModel)) { setPiModel(piModels[0]?.value ?? ''); } if (!piModels.some((m) => m.value === guidePiModel)) { setGuidePiModel(piModels[0]?.value ?? ''); } }, [piAvailable, piModels, piModel, setPiModel, guidePiModel, setGuidePiModel]); useEffect(() => { if (!copilotAvailable) return; if (!copilotModels.some((m) => m.value === copilotModel)) { setCopilotModel(copilotModels[0]?.value ?? ''); } if (!copilotModels.some((m) => m.value === guideCopilotModel)) { setGuideCopilotModel(copilotModels[0]?.value ?? ''); } }, [copilotAvailable, copilotModels, copilotModel, setCopilotModel, guideCopilotModel, setGuideCopilotModel]); // Annotation counts per job source const annotationCounts = useMemo(() => { const counts = new Map(); for (const ann of externalAnnotations) { if (ann.source) { counts.set(ann.source, (counts.get(ann.source) ?? 0) + 1); } } return counts; }, [externalAnnotations]); // Sort: running first, then by startedAt descending const sortedJobs = useMemo(() => { return [...jobs].sort((a, b) => { const aRunning = !isTerminalStatus(a.status); const bRunning = !isTerminalStatus(b.status); if (aRunning !== bRunning) return aRunning ? -1 : 1; return b.startedAt - a.startedAt; }); }, [jobs]); const runningCount = useMemo( () => jobs.filter((j) => !isTerminalStatus(j.status)).length, [jobs], ); // A persisted review id can point at something not in the current list: the // profiles may not be loaded yet, or the skill was removed by hand. Treat // anything not in the list as Default, for both the dropdown and the launch. const effectiveReviewProfileId = reviewProfiles.some((p) => p.id === reviewProfileId) ? reviewProfileId : 'builtin:default'; type LaunchParams = AgentLaunchParams; const buildReviewLaunch = (engine: ReviewEngine): LaunchParams => { // Carry the chosen review only when it is a custom one. Absent → the server // resolves to the built-in default. const review = effectiveReviewProfileId !== 'builtin:default' ? { reviewProfileId: effectiveReviewProfileId } : {}; if (engine === 'claude') { return { provider: 'claude', label: 'Code Review', model: claudeModel, effort: claudeEffort, ...review }; } if (engine === 'cursor') { // Omission ⇒ auto: drop the model client-side when it's `auto` so the POST // carries no model and the server lets Cursor pick its default. return { provider: 'cursor', label: 'Code Review', ...(cursorModel && cursorModel.toLowerCase() !== 'auto' ? { model: cursorModel } : {}), ...review, }; } if (engine === 'opencode') { // Empty model ⇒ OpenCode's configured default; only send a real model id. return { provider: 'opencode', label: 'Code Review', ...(opencodeModel ? { model: opencodeModel } : {}), ...review, }; } if (engine === 'pi') { // Empty model ⇒ Pi's own default; only send a real model id. return { provider: 'pi', label: 'Code Review', ...(piModel ? { model: piModel } : {}), thinking: piThinking, ...review, }; } if (engine === 'copilot') { // Empty model ⇒ Copilot's own pick; only send a real model id. return { provider: 'copilot', label: 'Code Review', ...(copilotModel ? { model: copilotModel } : {}), ...review, }; } return { provider: 'codex', label: 'Code Review', model: codexModel, reasoningEffort: codexReasoning, ...(codexFast && { fastMode: true }), ...review, }; }; const buildTourLaunch = (): LaunchParams => ({ provider: 'tour', label: 'Code Tour', engine: tourEngine, model: tourEngine === 'claude' ? tourClaudeModel : tourCodexModel, ...(tourEngine === 'claude' ? { effort: tourClaudeEffort } : { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }), }); // Guide extra instructions (#1265) are server-stored: this surface has no // editor, so it sends none and the server applies the stored standing // instructions itself, keeping sidebar launches identical to launch-page // ones without a second read path. const buildGuideLaunch = (): LaunchParams => buildGuideEngineParams(); const buildGuideEngineParams = (): LaunchParams => { if (guideEngine === 'cursor') { // Same omission rules as buildReviewLaunch: auto/empty ⇒ engine default. // Guide-scoped model — deliberately NOT the shared cursorModel (see // guideCursorModel's definition in useAgentSettings). return { provider: 'guide', label: 'Guided Review', engine: 'cursor', ...(guideCursorModel && guideCursorModel.toLowerCase() !== 'auto' ? { model: guideCursorModel } : {}), }; } if (guideEngine === 'opencode') { return { provider: 'guide', label: 'Guided Review', engine: 'opencode', ...(guideOpencodeModel ? { model: guideOpencodeModel } : {}), }; } if (guideEngine === 'pi') { return { provider: 'guide', label: 'Guided Review', engine: 'pi', ...(guidePiModel ? { model: guidePiModel } : {}), thinking: guidePiThinking, }; } if (guideEngine === 'copilot') { return { provider: 'guide', label: 'Guided Review', engine: 'copilot', ...(guideCopilotModel ? { model: guideCopilotModel } : {}), }; } return { provider: 'guide', label: 'Guided Review', engine: guideEngine, model: guideEngine === 'claude' ? guideClaudeModel : guideCodexModel, ...(guideEngine === 'claude' ? { effort: guideClaudeEffort } : { reasoningEffort: guideCodexReasoning }), }; }; // For a custom pick, hold launch until the profile list has loaded — otherwise // the saved id can't be found yet and the launch would quietly run Default. A // Default pick has nothing to resolve, so it never waits. const reviewReady = profilesLoaded || reviewProfileId === 'builtin:default'; // Claude/Codex launches wait for the model catalog to settle (it always // does — the fallback on failure), so a launch never uses an unresolved pick. const modelsReady = (engine: ReviewEngine) => engine === 'claude' || engine === 'codex' ? catalogs[engine].settled : true; const canLaunch = selectedMode === 'review' ? reviewEngineAvailable(reviewEngine) && reviewReady && modelsReady(reviewEngine) : selectedMode === 'tour' ? tourAvailable && engineAvailable(tourEngine) && modelsReady(tourEngine) : selectedMode === 'guide' ? guideAvailable && reviewEngineAvailable(guideEngine) && guideLaunchable && modelsReady(guideEngine) : false; const handleLaunch = async () => { if (!canLaunch || launchingRef.current) return; const params = selectedMode === 'review' ? buildReviewLaunch(reviewEngine) : selectedMode === 'tour' ? buildTourLaunch() : buildGuideLaunch(); launchingRef.current = true; setPendingLaunch({ label: params.label ?? 'Agent job', ...(params.provider && { provider: params.provider }), startedAt: Date.now(), }); setLaunchError(null); try { const result = await onLaunch(params); if (result === null) { setLaunchError('Could not start agent job.'); } } catch (error) { setLaunchError(error instanceof Error ? error.message : 'Could not start agent job.'); } finally { launchingRef.current = false; setPendingLaunch(null); } }; const modeOptions = availableModes.map((mode) => ({ value: mode, label: MODE_LABEL[mode] })); const renderStaticChoice = (label: string, icon?: React.ReactNode) => (
{icon} {label}
); // Icon-button engine row, shared by Tour (narrow claude/codex set) and Review // (wide claude/codex/cursor/opencode set). The caller passes the engine list // plus its icon/label maps so the same control renders four equal options for // Review exactly as it renders two for Tour. function renderEngineSelect( value: E, onChange: (engine: E) => void, engines: E[], iconMap: Record>, labelMap: Record, configLabel: string = 'Engine', ) { const StaticIcon: React.FC<{ className?: string }> = iconMap[value]; return ( {engines.length > 1 ? ( // Tap an agent's mark to pick it — no dropdown.
{engines.map((engine) => { const Icon: React.FC<{ className?: string }> = iconMap[engine]; const selected = value === engine; return ( ); })}
) : ( renderStaticChoice(labelMap[value], ) )}
); } // Model picker for Claude and Codex, or a quiet loading row while that // engine's catalog is still settling (launch is held until it does). const renderCatalogModelPicker = ( engine: 'claude' | 'codex', value: string, onChange: (value: string) => void, ) => ( {catalogs[engine].settled ? ( <> ) : ( renderStaticChoice('Loading models…', ) )} ); // Effort / reasoning picker for Claude and Codex: only the levels the // selected model accepts, and no picker at all for a model that takes none. const renderEffortPicker = ( label: string, models: readonly CatalogModel[], model: string, value: string, onChange: (value: string) => void, ) => { const options = effortSelectOptions(models, model); if (options.length === 0) return null; return ( ); }; // Cursor and OpenCode share the same review config: an "experimental" note and // a single model picker driven by their live (or fallback) catalog. const renderMarkerEngineConfig = ( model: string, models: Array<{ value: string; label: string }>, setModel: (value: string) => void, ) => ( <>
experimental Findings are prompt-enforced
{models.length > 1 ? ( ) : ( renderStaticChoice(catalogLabel(models, model)) )} ); return (
{/* Launch panel (pinned to the top) */} {availableModes.length > 0 && (
Launch agent
{availableModes.length > 1 ? ( setSelectedMode(next as AgentMode)} icon={} placeholder="Select mode" /> ) : ( renderStaticChoice( availableModes[0] ? MODE_LABEL[availableModes[0]] : '', , ) )} {selectedMode === 'review' && ( <> {/* Provider (engine) picker — icon-button row over the wide set (claude/codex/cursor/opencode), above the review selector. */} {renderEngineSelect( reviewEngine, setReviewEngine, availableReviewEngines, REVIEW_ENGINE_ICON, REVIEW_ENGINE_LABEL, 'Provider', )} ({ value: p.id, label: p.label }))} onChange={setReviewProfileId} footerAction={{ label: 'Add new review', onClick: () => setAddReviewOpen(true) }} /> {reviewEngine === 'claude' && ( <> {renderCatalogModelPicker('claude', claudeModel, setClaudeModel)} {renderEffortPicker('Effort', catalogs.claude.models, claudeModel, claudeEffort, setClaudeEffort)} )} {reviewEngine === 'codex' && ( <> {renderCatalogModelPicker('codex', codexModel, setCodexModel)} {renderEffortPicker('Reasoning', catalogs.codex.models, codexModel, codexReasoning, setCodexReasoning)} {modelSupportsFast(catalogs.codex.models, codexModel) && ( )} )} {reviewEngine === 'cursor' && renderMarkerEngineConfig(cursorModel, cursorModels, setCursorModel)} {reviewEngine === 'opencode' && renderMarkerEngineConfig(opencodeModel, opencodeModels, setOpencodeModel)} {reviewEngine === 'pi' && ( <> {renderMarkerEngineConfig(piModel, piModels, setPiModel)} )} {reviewEngine === 'copilot' && renderMarkerEngineConfig(copilotModel, copilotModels, setCopilotModel)} )} {selectedMode === 'tour' && ( <> {renderEngineSelect(tourEngine, setTourEngine, availableEngines, ENGINE_ICON, ENGINE_LABEL)} {tourEngine === 'claude' ? renderCatalogModelPicker('claude', tourClaudeModel, setTourClaudeModel) : renderCatalogModelPicker('codex', tourCodexModel, setTourCodexModel)} {/* Claude-only: effort level */} {tourEngine === 'claude' && renderEffortPicker('Effort', catalogs.claude.models, tourClaudeModel, tourClaudeEffort, setTourClaudeEffort)} {/* Codex-only: reasoning effort + fast mode */} {tourEngine === 'codex' && ( <> {renderEffortPicker('Reasoning', catalogs.codex.models, tourCodexModel, tourCodexReasoning, setTourCodexReasoning)} {modelSupportsFast(catalogs.codex.models, tourCodexModel) && ( )} )} )} {selectedMode === 'guide' && ( <> {renderEngineSelect(guideEngine, setGuideEngine, availableReviewEngines, REVIEW_ENGINE_ICON, REVIEW_ENGINE_LABEL)} {guideEngine === 'claude' && renderCatalogModelPicker('claude', guideClaudeModel, setGuideClaudeModel)} {guideEngine === 'codex' && renderCatalogModelPicker('codex', guideCodexModel, setGuideCodexModel)} {/* Claude-only: effort level */} {guideEngine === 'claude' && renderEffortPicker('Effort', catalogs.claude.models, guideClaudeModel, guideClaudeEffort, setGuideClaudeEffort)} {/* Codex-only: reasoning effort. No "Fast mode" toggle here (unlike review/tour's codex blocks above) — fast mode is deliberately not offered for guide. */} {guideEngine === 'codex' && renderEffortPicker('Reasoning', catalogs.codex.models, guideCodexModel, guideCodexReasoning, setGuideCodexReasoning)} {/* Marker engines: same live-catalog model picker as review mode, but bound to the guide-scoped settings (see useAgentSettings) so tuning these doesn't change the next Cursor/OpenCode/Pi review. */} {guideEngine === 'cursor' && renderMarkerEngineConfig(guideCursorModel, cursorModels, setGuideCursorModel)} {guideEngine === 'opencode' && renderMarkerEngineConfig(guideOpencodeModel, opencodeModels, setGuideOpencodeModel)} {guideEngine === 'pi' && ( <> {renderMarkerEngineConfig(guidePiModel, piModels, setGuidePiModel)} )} {guideEngine === 'copilot' && renderMarkerEngineConfig(guideCopilotModel, copilotModels, setGuideCopilotModel)} )}
{launchError && (

{launchError}

)}
)} {/* Job list (scrolls; launch controls are pinned above) */}
{pendingLaunch && ( )} {sortedJobs.length === 0 && !pendingLaunch ? (

No agent jobs

Launch an agent above

) : ( sortedJobs.map((job) => ( onKillJob(job.id)} expanded={expandedJobId === job.id} onToggle={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)} onViewDetails={onOpenJobDetail ? () => onOpenJobDetail(job.id) : undefined} onOpenGuide={onOpenGuide && (canOpenGuideJob?.(job) ?? true) ? () => onOpenGuide(job.id) : undefined} /> )) )}
{/* Kill all — pinned at the bottom */} {runningCount >= 2 && (
)} {addReviewOpen && ( setAddReviewOpen(false)} onEnabled={(name) => { const id = `skill:${name}`; // Add optimistically so the dropdown can select it immediately; the // refresh below reconciles against the server. setReviewProfiles((prev) => (prev.some((p) => p.id === id) ? prev : [...prev, { id, label: name }])); setReviewProfileId(id); setAddReviewOpen(false); refreshReviewProfiles(); }} /> )}
); };