From 3b638194025ae71d90eef0b5153bccefeebff170 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Sun, 18 Jan 2026 15:09:49 +0000 Subject: [PATCH] refactor: merge backend selection into agent dropdown Simplify the Create New Mission dialog by removing the separate Backend dropdown and combining it with Agent selection. Agents are now grouped by backend (OpenCode, Claude Code) using optgroups. --- .../src/components/new-mission-dialog.tsx | 219 +++++++++++------- 1 file changed, 136 insertions(+), 83 deletions(-) diff --git a/dashboard/src/components/new-mission-dialog.tsx b/dashboard/src/components/new-mission-dialog.tsx index 022ae4d..ccaf9d9 100644 --- a/dashboard/src/components/new-mission-dialog.tsx +++ b/dashboard/src/components/new-mission-dialog.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import { Plus } from 'lucide-react'; import useSWR from 'swr'; -import { getVisibleAgents, getOpenAgentConfig, listBackends, listBackendAgents, type Backend, type BackendAgent } from '@/lib/api'; +import { getVisibleAgents, getOpenAgentConfig, listBackends, listBackendAgents, getBackendConfig, type Backend, type BackendAgent } from '@/lib/api'; import type { Provider, Workspace } from '@/lib/api'; interface NewMissionDialogProps { @@ -18,6 +18,14 @@ interface NewMissionDialogProps { }) => Promise | void; } +// Combined agent with backend info +interface CombinedAgent { + backend: string; + backendName: string; + agent: string; + value: string; // "backend:agent" format +} + // Parse agent names from API response const parseAgentNames = (payload: unknown): string[] => { const normalizeEntry = (entry: unknown): string | null => { @@ -50,9 +58,9 @@ export function NewMissionDialog({ }: NewMissionDialogProps) { const [open, setOpen] = useState(false); const [newMissionWorkspace, setNewMissionWorkspace] = useState(''); - const [newMissionAgent, setNewMissionAgent] = useState(''); + // Combined value: "backend:agent" or empty for default + const [selectedAgentValue, setSelectedAgentValue] = useState(''); const [newMissionModelOverride, setNewMissionModelOverride] = useState(''); - const [newMissionBackend, setNewMissionBackend] = useState('opencode'); const [submitting, setSubmitting] = useState(false); const [defaultSet, setDefaultSet] = useState(false); const dialogRef = useRef(null); @@ -64,14 +72,38 @@ export function NewMissionDialog({ fallbackData: [{ id: 'opencode', name: 'OpenCode' }, { id: 'claudecode', name: 'Claude Code' }], }); - // SWR: fetch agents for selected backend - const { data: backendAgents, isLoading: backendAgentsLoading } = useSWR( - newMissionBackend ? `backend-${newMissionBackend}-agents` : null, - () => listBackendAgents(newMissionBackend), + // SWR: fetch backend configs to check enabled status + const { data: opencodeConfig } = useSWR('backend-opencode-config', () => getBackendConfig('opencode'), { + revalidateOnFocus: false, + dedupingInterval: 30000, + }); + const { data: claudecodeConfig } = useSWR('backend-claudecode-config', () => getBackendConfig('claudecode'), { + revalidateOnFocus: false, + dedupingInterval: 30000, + }); + + // Filter to only enabled backends + const enabledBackends = useMemo(() => { + return backends?.filter((b) => { + if (b.id === 'opencode') return opencodeConfig?.enabled !== false; + if (b.id === 'claudecode') return claudecodeConfig?.enabled !== false; + return true; + }) || []; + }, [backends, opencodeConfig, claudecodeConfig]); + + // SWR: fetch agents for each enabled backend + const { data: opencodeAgents } = useSWR( + enabledBackends.some(b => b.id === 'opencode') ? 'backend-opencode-agents' : null, + () => listBackendAgents('opencode'), + { revalidateOnFocus: false, dedupingInterval: 30000 } + ); + const { data: claudecodeAgents } = useSWR( + enabledBackends.some(b => b.id === 'claudecode') ? 'backend-claudecode-agents' : null, + () => listBackendAgents('claudecode'), { revalidateOnFocus: false, dedupingInterval: 30000 } ); - // SWR: fetch once, cache globally, revalidate in background (fallback for agent list) + // SWR: fallback for opencode agents const { data: agentsPayload } = useSWR('opencode-agents', getVisibleAgents, { revalidateOnFocus: false, dedupingInterval: 30000, @@ -81,11 +113,51 @@ export function NewMissionDialog({ dedupingInterval: 30000, }); - // Parse agents from backend API (only use fallback for opencode backend) - // For non-opencode backends, wait for backendAgents to load to avoid race condition - const agents = newMissionBackend === 'opencode' - ? (backendAgents?.map(a => a.name) || parseAgentNames(agentsPayload)) - : (backendAgents?.map(a => a.name) || []); + // Combine all agents from enabled backends + const allAgents = useMemo((): CombinedAgent[] => { + const result: CombinedAgent[] = []; + + for (const backend of enabledBackends) { + let agentNames: string[] = []; + + if (backend.id === 'opencode') { + agentNames = opencodeAgents?.map(a => a.name) || parseAgentNames(agentsPayload); + } else if (backend.id === 'claudecode') { + agentNames = claudecodeAgents?.map(a => a.name) || []; + } + + for (const agent of agentNames) { + result.push({ + backend: backend.id, + backendName: backend.name, + agent, + value: `${backend.id}:${agent}`, + }); + } + } + + return result; + }, [enabledBackends, opencodeAgents, claudecodeAgents, agentsPayload]); + + // Group agents by backend for display + const agentsByBackend = useMemo(() => { + const groups: Record = {}; + for (const agent of allAgents) { + if (!groups[agent.backend]) { + groups[agent.backend] = []; + } + groups[agent.backend].push(agent); + } + return groups; + }, [allAgents]); + + // Parse selected value to get backend and agent + const parseSelectedValue = (value: string): { backend: string; agent: string } | null => { + if (!value) return null; + const [backend, ...agentParts] = value.split(':'); + const agent = agentParts.join(':'); // Handle agent names with colons + return backend && agent ? { backend, agent } : null; + }; const formatWorkspaceType = (type: Workspace['workspace_type']) => type === 'host' ? 'host' : 'isolated'; @@ -105,29 +177,42 @@ export function NewMissionDialog({ }, [open]); // Set default agent when dialog opens (only once per open) - // Wait for both agents AND config to load before setting defaults useEffect(() => { if (!open || defaultSet) return; - // Wait for config to finish loading (undefined = still loading, null/object = loaded) + // Wait for config to finish loading if (config === undefined) return; - // Wait for backend agents to finish loading (avoid race condition when switching backends) - if (backendAgentsLoading) return; - // If no agents available yet, wait - if (agents.length === 0) return; + // Wait for agents to load + if (allAgents.length === 0) return; - if (config?.default_agent && agents.includes(config.default_agent)) { - setNewMissionAgent(config.default_agent); - } else if (agents.includes('Sisyphus')) { - setNewMissionAgent('Sisyphus'); + // Try to find the default agent from config + if (config?.default_agent) { + const defaultAgent = allAgents.find(a => a.agent === config.default_agent); + if (defaultAgent) { + setSelectedAgentValue(defaultAgent.value); + setDefaultSet(true); + return; + } + } + + // Fallback: try Sisyphus in OpenCode + const sisyphus = allAgents.find(a => a.backend === 'opencode' && a.agent === 'Sisyphus'); + if (sisyphus) { + setSelectedAgentValue(sisyphus.value); + setDefaultSet(true); + return; + } + + // Fallback: use first available agent + if (allAgents.length > 0) { + setSelectedAgentValue(allAgents[0].value); } setDefaultSet(true); - }, [open, defaultSet, agents, config, backendAgentsLoading]); + }, [open, defaultSet, allAgents, config]); const resetForm = () => { setNewMissionWorkspace(''); - setNewMissionAgent(''); + setSelectedAgentValue(''); setNewMissionModelOverride(''); - setNewMissionBackend('opencode'); setDefaultSet(false); }; @@ -140,11 +225,12 @@ export function NewMissionDialog({ if (disabled || submitting) return; setSubmitting(true); try { + const parsed = parseSelectedValue(selectedAgentValue); await onCreate({ workspaceId: newMissionWorkspace || undefined, - agent: newMissionAgent || undefined, + agent: parsed?.agent || undefined, modelOverride: newMissionModelOverride || undefined, - backend: newMissionBackend || undefined, + backend: parsed?.backend || 'opencode', }); setOpen(false); resetForm(); @@ -154,7 +240,9 @@ export function NewMissionDialog({ }; const isBusy = disabled || submitting; - const defaultAgentLabel = 'Default (OpenCode default)'; + + // Determine default label based on enabled backends + const defaultBackendName = enabledBackends[0]?.name || 'OpenCode'; return (
@@ -209,17 +297,12 @@ export function NewMissionDialog({

Where the mission will run

- {/* Backend selection */} + {/* Agent selection (includes backend) */}
- + -

AI coding backend to power this mission

-
+ {enabledBackends.map((backend) => { + const backendAgentsList = agentsByBackend[backend.id] || []; + if (backendAgentsList.length === 0) return null; - {/* Agent selection */} -
- -

- Agents are provided by plugins; defaults are recommended + Select an agent and backend to power this mission