From 15d416c0aa39705dd6158c67bc646484aee24e6b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 14 Mar 2026 06:49:26 +0000 Subject: [PATCH 1/3] feat(dashboard): merge agent config modal into expandable sections --- .../projects/project-agent-configs.tsx | 399 ++++++++++-------- 1 file changed, 219 insertions(+), 180 deletions(-) diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index cd4032cd..943e4916 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,7 +22,7 @@ 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 { ChevronDown, ChevronRight } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -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,9 @@ 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; onDeleteConfig: (id: number) => void; onTriggerToggle: (agentType: string, event: string, enabled: boolean) => void; onTriggerParamChange: ( @@ -77,12 +90,30 @@ function DefinitionAgentSection({ config, triggers, integrations, - onEditConfig, + engines, + isSaving, + onSaveConfig, onDeleteConfig, onTriggerToggle, onTriggerParamChange, }: DefinitionAgentSectionProps) { const [expanded, setExpanded] = useState(false); + const [saved, setSaved] = useState(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 + useEffect(() => { + setModel(config?.model ?? ''); + setMaxIterations(config?.maxIterations?.toString() ?? ''); + setAgentEngine(config?.agentEngine ?? ''); + setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); + setSaved(false); + }, [config]); // Group triggers by category and filter by active integrations const triggersByCategory = useMemo(() => { @@ -114,6 +145,30 @@ function DefinitionAgentSection({ triggersByCategory.scm.length > 0 || triggersByCategory.internal.length > 0; + const handleSave = () => { + onSaveConfig(agentType, config?.id ?? null, { + model, + maxIterations, + agentEngine, + maxConcurrency, + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + 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 +188,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 +291,37 @@ function DefinitionAgentSection({ No trigger configuration for this agent.

)} + + {/* Footer actions */} +
+
+ + + {saved && Saved} +
+ {config && ( + + )} +
)} @@ -202,7 +332,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 +347,10 @@ 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 [savingAgentType, setSavingAgentType] = useState(null); const configsQueryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey; const triggersViewQueryKey = trpc.agentTriggerConfigs.getProjectTriggersView.queryOptions({ @@ -235,56 +358,57 @@ 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: () => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); - setDialogOpen(false); + setSavingAgentType(null); + }, + 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: () => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); - setDialogOpen(false); + setSavingAgentType(null); + }, + onError: (err) => { + toast.error('Failed to update agent config', { description: err.message }); + setSavingAgentType(null); }, }); @@ -348,6 +472,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 +492,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); } }; @@ -425,8 +562,6 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { } }; - const activeMutation = editing ? updateMutation : createMutation; - // Get list of agent types to display const agentTypes = Array.from(triggersByAgent.keys()); @@ -445,7 +580,11 @@ 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} onDeleteConfig={(id) => deleteMutation.mutate(id)} onTriggerToggle={handleTriggerToggle} onTriggerParamChange={handleTriggerParamChange} @@ -481,106 +620,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}

- )} -
-
-
); } From c06f91e51e027697d10c4f8a7ee83aa68a65da3e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 14 Mar 2026 07:03:31 +0000 Subject: [PATCH 2/3] fix(dashboard): move Saved indicator after confirmed save and clean up timers - setSaved(true) now fires only after the mutation's onSuccess callback, preventing "Saved" text showing when the mutation fails - Save success is signalled via a per-agent saveSuccessNonce counter that increments on each confirmed onSuccess, so repeated saves always trigger the useEffect in DefinitionAgentSection - setTimeout IDs are stored in useRef and cleared in useEffect cleanup to avoid state updates on unmounted components (fixes both agent section and lifecycle triggers section) Co-Authored-By: Claude Opus 4.6 --- .../projects/project-agent-configs.tsx | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 943e4916..b17b4f66 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -23,7 +23,7 @@ import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { ChevronDown, ChevronRight } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; interface AgentConfig { @@ -75,6 +75,7 @@ interface DefinitionAgentSectionProps { 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: ( @@ -93,12 +94,14 @@ function DefinitionAgentSection({ engines, isSaving, onSaveConfig, + saveSuccessNonce, onDeleteConfig, onTriggerToggle, onTriggerParamChange, }: DefinitionAgentSectionProps) { const [expanded, setExpanded] = useState(false); const [saved, setSaved] = useState(false); + const savedTimerRef = useRef | null>(null); // Local form state const [model, setModel] = useState(config?.model ?? ''); @@ -115,6 +118,25 @@ function DefinitionAgentSection({ setSaved(false); }, [config]); + // Show "Saved" indicator only after confirmed persistence (nonce increments on each success) + useEffect(() => { + if (saveSuccessNonce === 0) return; + 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(() => { const groups: Record = { @@ -152,8 +174,6 @@ function DefinitionAgentSection({ agentEngine, maxConcurrency, }); - setSaved(true); - setTimeout(() => setSaved(false), 2000); }; const handleCancel = () => { @@ -350,7 +370,9 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { 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({ @@ -375,9 +397,13 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: input.agentEngine, maxConcurrency: input.maxConcurrency, }), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); setSavingAgentType(null); + setSaveSuccessNonces((prev) => ({ + ...prev, + [variables.agentType]: (prev[variables.agentType] ?? 0) + 1, + })); }, onError: (err) => { toast.error('Failed to create agent config', { description: err.message }); @@ -402,9 +428,13 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: input.agentEngine, maxConcurrency: input.maxConcurrency, }), - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: configsQueryKey }); setSavingAgentType(null); + setSaveSuccessNonces((prev) => ({ + ...prev, + [variables.agentType]: (prev[variables.agentType] ?? 0) + 1, + })); }, onError: (err) => { toast.error('Failed to update agent config', { description: err.message }); @@ -464,6 +494,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; @@ -555,8 +594,11 @@ 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); } @@ -585,6 +627,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { savingAgentType === type && (createMutation.isPending || updateMutation.isPending) } onSaveConfig={handleSaveConfig} + saveSuccessNonce={saveSuccessNonces[type] ?? 0} onDeleteConfig={(id) => deleteMutation.mutate(id)} onTriggerToggle={handleTriggerToggle} onTriggerParamChange={handleTriggerParamChange} From 099dbe0ffbdb1b4499b34556a3b30056234ae363 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 14 Mar 2026 07:13:44 +0000 Subject: [PATCH 3/3] fix(dashboard): fix saved indicator race condition and add delete error handling - Add `justSavedRef` to prevent the config sync effect from clearing the "Saved" indicator when `invalidateQueries` triggers a refetch after save - Set `justSavedRef.current = true` in the nonce effect before showing the indicator, cleared by the next config sync cycle - Add `onError` toast to `deleteMutation` for consistent error feedback alongside create/update mutations Co-Authored-By: Claude Opus 4.6 --- .../projects/project-agent-configs.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index b17b4f66..dd998781 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -102,6 +102,8 @@ function DefinitionAgentSection({ 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 ?? ''); @@ -109,18 +111,25 @@ function DefinitionAgentSection({ const [agentEngine, setAgentEngine] = useState(config?.agentEngine ?? ''); const [maxConcurrency, setMaxConcurrency] = useState(config?.maxConcurrency?.toString() ?? ''); - // Sync form state when config changes + // 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() ?? ''); - setSaved(false); + 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); } @@ -445,6 +454,9 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { 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)