diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index cd4032cd..dd998781 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -4,7 +4,6 @@ import { type ResolvedTrigger, } from '@/components/shared/definition-trigger-toggles.js'; import { TriggerToggles } from '@/components/shared/trigger-toggles.js'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { @@ -23,8 +22,8 @@ import { import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; interface AgentConfig { @@ -36,6 +35,11 @@ interface AgentConfig { maxConcurrency: number | null; } +interface Engine { + id: string; + label: string; +} + function AgentConfigBadge({ config }: { config: AgentConfig | null }) { if (!config) { return Using defaults; @@ -53,6 +57,13 @@ function AgentConfigBadge({ config }: { config: AgentConfig | null }) { // Definition-Based Agent Section (New) // ============================================================================ +interface SaveConfigValues { + model: string; + maxIterations: string; + agentEngine: string; + maxConcurrency: string; +} + interface DefinitionAgentSectionProps { agentType: string; config: AgentConfig | null; @@ -61,7 +72,10 @@ interface DefinitionAgentSectionProps { pm: string | null; scm: string | null; }; - onEditConfig: (config: AgentConfig | null, agentType: string) => void; + engines: Engine[]; + isSaving: boolean; + onSaveConfig: (agentType: string, configId: number | null, values: SaveConfigValues) => void; + saveSuccessNonce: number; onDeleteConfig: (id: number) => void; onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; onTriggerParamChange: ( @@ -77,12 +91,60 @@ function DefinitionAgentSection({ config, triggers, integrations, - onEditConfig, + engines, + isSaving, + onSaveConfig, + saveSuccessNonce, onDeleteConfig, onTriggerToggle, onTriggerParamChange, }: DefinitionAgentSectionProps) { const [expanded, setExpanded] = useState(false); + const [saved, setSaved] = useState(false); + const savedTimerRef = useRef | null>(null); + // Tracks whether a successful save is in flight (prevents config sync from clearing "Saved") + const justSavedRef = useRef(false); + + // Local form state + const [model, setModel] = useState(config?.model ?? ''); + const [maxIterations, setMaxIterations] = useState(config?.maxIterations?.toString() ?? ''); + const [agentEngine, setAgentEngine] = useState(config?.agentEngine ?? ''); + const [maxConcurrency, setMaxConcurrency] = useState(config?.maxConcurrency?.toString() ?? ''); + + // Sync form state when config changes (e.g. after invalidateQueries refetch) + // Skip clearing "Saved" if we just saved — the nonce effect will handle the timer + useEffect(() => { + setModel(config?.model ?? ''); + setMaxIterations(config?.maxIterations?.toString() ?? ''); + setAgentEngine(config?.agentEngine ?? ''); + setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); + if (justSavedRef.current) { + justSavedRef.current = false; + } else { + setSaved(false); + } + }, [config]); + + // Show "Saved" indicator only after confirmed persistence (nonce increments on each success) + useEffect(() => { + if (saveSuccessNonce === 0) return; + // Mark that a save just completed so the config sync effect won't clear the indicator + justSavedRef.current = true; + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + } + setSaved(true); + savedTimerRef.current = setTimeout(() => setSaved(false), 2000); + }, [saveSuccessNonce]); + + // Clean up the "Saved" timer on unmount to avoid state updates on unmounted component + useEffect(() => { + return () => { + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + } + }; + }, []); // Group triggers by category and filter by active integrations const triggersByCategory = useMemo(() => { @@ -114,6 +176,28 @@ function DefinitionAgentSection({ triggersByCategory.scm.length > 0 || triggersByCategory.internal.length > 0; + const handleSave = () => { + onSaveConfig(agentType, config?.id ?? null, { + model, + maxIterations, + agentEngine, + maxConcurrency, + }); + }; + + const handleCancel = () => { + setModel(config?.model ?? ''); + setMaxIterations(config?.maxIterations?.toString() ?? ''); + setAgentEngine(config?.agentEngine ?? ''); + setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); + }; + + const handleDelete = () => { + if (config && window.confirm('Delete this agent config?')) { + onDeleteConfig(config.id); + } + }; + return (
{/* Header */} @@ -133,36 +217,80 @@ function DefinitionAgentSection({
-
- - {config && ( - - )} -
{/* Expanded content */} {expanded && (
+ {/* Config fields */} +
+

+ Configuration +

+
+
+ + +
+
+ + setMaxIterations(e.target.value)} + placeholder="Optional" + /> +
+
+
+
+ + setMaxConcurrency(e.target.value)} + placeholder="Optional" + /> +
+
+ + +
+
+
+ +

+ Prompts are managed in{' '} + + Agent Definitions + +

+
+
+ {/* Render triggers by category */} {(['pm', 'scm', 'internal'] as const).map((category) => { const categoryTriggers = triggersByCategory[category]; @@ -192,6 +320,37 @@ function DefinitionAgentSection({ No trigger configuration for this agent.

)} + + {/* Footer actions */} +
+
+ + + {saved && Saved} +
+ {config && ( + + )} +
)} @@ -202,7 +361,6 @@ function DefinitionAgentSection({ // Main Component // ============================================================================ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: manages multiple mutations + state for agent configs and trigger updates export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const queryClient = useQueryClient(); @@ -218,16 +376,12 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { // Integrations query (for lifecycle triggers) const integrationsQuery = useQuery(trpc.projects.integrations.list.queryOptions({ projectId })); - const [dialogOpen, setDialogOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [agentType, setAgentType] = useState(''); - const [model, setModel] = useState(''); - const [maxIterations, setMaxIterations] = useState(''); - const [agentEngine, setAgentEngine] = useState(''); - const [maxConcurrency, setMaxConcurrency] = useState(''); const [localLifecycleTriggers, setLocalLifecycleTriggers] = useState>({}); const [lifecycleSaving, setLifecycleSaving] = useState(false); const [lifecycleSaved, setLifecycleSaved] = useState(false); + const lifecycleSavedTimerRef = useRef | null>(null); + const [savingAgentType, setSavingAgentType] = useState(null); + const [saveSuccessNonces, setSaveSuccessNonces] = useState>({}); const configsQueryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey; const triggersViewQueryKey = trpc.agentTriggerConfigs.getProjectTriggersView.queryOptions({ @@ -235,62 +389,74 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { }).queryKey; const integrationsQueryKey = trpc.projects.integrations.list.queryOptions({ projectId }).queryKey; - function openCreate(defaultAgentType: string) { - setEditing(null); - setAgentType(defaultAgentType); - setModel(''); - setMaxIterations(''); - setAgentEngine(''); - setMaxConcurrency(''); - setDialogOpen(true); - } - - function openEdit(config: AgentConfig) { - setEditing(config); - setAgentType(config.agentType); - setModel(config.model ?? ''); - setMaxIterations(config.maxIterations?.toString() ?? ''); - setAgentEngine(config.agentEngine ?? ''); - setMaxConcurrency(config.maxConcurrency?.toString() ?? ''); - setDialogOpen(true); - } - // Agent config mutations (shared) const createMutation = useMutation({ - mutationFn: () => + mutationFn: (input: { + agentType: string; + model: string | null; + maxIterations: number | null; + agentEngine: string | null; + maxConcurrency: number | null; + }) => trpcClient.agentConfigs.create.mutate({ projectId, - agentType, - model: model || null, - maxIterations: maxIterations ? Number(maxIterations) : null, - agentEngine: agentEngine || null, - maxConcurrency: maxConcurrency ? Number(maxConcurrency) : null, + agentType: input.agentType, + model: input.model, + maxIterations: input.maxIterations, + agentEngine: input.agentEngine, + maxConcurrency: input.maxConcurrency, }), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); - setDialogOpen(false); + setSavingAgentType(null); + setSaveSuccessNonces((prev) => ({ + ...prev, + [variables.agentType]: (prev[variables.agentType] ?? 0) + 1, + })); + }, + onError: (err) => { + toast.error('Failed to create agent config', { description: err.message }); + setSavingAgentType(null); }, }); const updateMutation = useMutation({ - mutationFn: () => + mutationFn: (input: { + id: number; + agentType: string; + model: string | null; + maxIterations: number | null; + agentEngine: string | null; + maxConcurrency: number | null; + }) => trpcClient.agentConfigs.update.mutate({ - id: editing?.id as number, - agentType, - model: model || null, - maxIterations: maxIterations ? Number(maxIterations) : null, - agentEngine: agentEngine || null, - maxConcurrency: maxConcurrency ? Number(maxConcurrency) : null, + id: input.id, + agentType: input.agentType, + model: input.model, + maxIterations: input.maxIterations, + agentEngine: input.agentEngine, + maxConcurrency: input.maxConcurrency, }), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); - setDialogOpen(false); + setSavingAgentType(null); + setSaveSuccessNonces((prev) => ({ + ...prev, + [variables.agentType]: (prev[variables.agentType] ?? 0) + 1, + })); + }, + onError: (err) => { + toast.error('Failed to update agent config', { description: err.message }); + setSavingAgentType(null); }, }); const deleteMutation = useMutation({ mutationFn: (id: number) => trpcClient.agentConfigs.delete.mutate({ id }), onSuccess: () => queryClient.invalidateQueries({ queryKey: configsQueryKey }), + onError: (err) => { + toast.error('Failed to delete agent config', { description: err.message }); + }, }); // New trigger mutation (uses agentTriggerConfigs.upsert) @@ -340,6 +506,15 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { setLocalLifecycleTriggers(scmTriggers); }, [scmTriggers]); + // Clean up the lifecycle "Saved" timer on unmount + useEffect(() => { + return () => { + if (lifecycleSavedTimerRef.current !== null) { + clearTimeout(lifecycleSavedTimerRef.current); + } + }; + }, []); + // Loading state const isLoading = configsQuery.isLoading || triggersViewQuery.isLoading; @@ -348,6 +523,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } const configs = (configsQuery.data ?? []) as AgentConfig[]; + const engines = (enginesQuery.data ?? []) as Engine[]; // Build agent config map const configByAgent = new Map(); @@ -367,12 +543,24 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } } - // Handlers - const handleEditConfig = (config: AgentConfig | null, type: string) => { - if (config) { - openEdit(config); + const handleSaveConfig = ( + type: string, + configId: number | null, + values: { model: string; maxIterations: string; agentEngine: string; maxConcurrency: string }, + ) => { + setSavingAgentType(type); + const payload = { + agentType: type, + model: values.model || null, + maxIterations: values.maxIterations ? Number(values.maxIterations) : null, + agentEngine: values.agentEngine || null, + maxConcurrency: values.maxConcurrency ? Number(values.maxConcurrency) : null, + }; + + if (configId !== null) { + updateMutation.mutate({ id: configId, ...payload }); } else { - openCreate(type); + createMutation.mutate(payload); } }; @@ -418,15 +606,16 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } } await updateTriggersMutation.mutateAsync({ category: 'scm', triggers: changed }); + if (lifecycleSavedTimerRef.current !== null) { + clearTimeout(lifecycleSavedTimerRef.current); + } setLifecycleSaved(true); - setTimeout(() => setLifecycleSaved(false), 2000); + lifecycleSavedTimerRef.current = setTimeout(() => setLifecycleSaved(false), 2000); } finally { setLifecycleSaving(false); } }; - const activeMutation = editing ? updateMutation : createMutation; - // Get list of agent types to display const agentTypes = Array.from(triggersByAgent.keys()); @@ -445,7 +634,12 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { config={configByAgent.get(type) ?? null} triggers={triggersByAgent.get(type) ?? []} integrations={triggersViewIntegrations} - onEditConfig={handleEditConfig} + engines={engines} + isSaving={ + savingAgentType === type && (createMutation.isPending || updateMutation.isPending) + } + onSaveConfig={handleSaveConfig} + saveSuccessNonce={saveSuccessNonces[type] ?? 0} onDeleteConfig={(id) => deleteMutation.mutate(id)} onTriggerToggle={handleTriggerToggle} onTriggerParamChange={handleTriggerParamChange} @@ -481,106 +675,6 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { )} - - {/* Dialog for add/edit agent config */} - - - - {editing ? 'Edit Agent Config' : 'New Agent Config'} - -
{ - e.preventDefault(); - activeMutation.mutate(); - }} - className="space-y-4" - > -
- - )[agentType] ?? agentType} - readOnly - className="bg-muted" - /> -
-
-
- - -
-
- - setMaxIterations(e.target.value)} - placeholder="Optional" - /> -
-
-
- - setMaxConcurrency(e.target.value)} - placeholder="Optional — limits concurrent runs per project" - /> -
-
- - -
-
- -

- Prompts are managed in{' '} - - Agent Definitions - -

-
-
- - -
- {activeMutation.isError && ( -

{activeMutation.error.message}

- )} -
-
-
); }