From 9701b40023965575e47f311607217b528fa697fe Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 4 Apr 2026 14:46:47 +0000 Subject: [PATCH] refactor(web): decompose project-agent-configs.tsx and integration-form.tsx --- tests/unit/web/agent-config-utils.test.ts | 164 ++++ .../projects/agent-config-detail.tsx | 414 ++++++++ .../components/projects/agent-config-list.tsx | 275 ++++++ .../components/projects/agent-config-types.ts | 167 ++++ .../components/projects/agent-config-utils.ts | 40 + .../projects/integration-alerting-tab.tsx | 198 ++++ .../components/projects/integration-form.tsx | 616 +----------- .../projects/integration-scm-tab.tsx | 434 +++++++++ .../projects/project-agent-configs.tsx | 889 +----------------- 9 files changed, 1709 insertions(+), 1488 deletions(-) create mode 100644 tests/unit/web/agent-config-utils.test.ts create mode 100644 web/src/components/projects/agent-config-detail.tsx create mode 100644 web/src/components/projects/agent-config-list.tsx create mode 100644 web/src/components/projects/agent-config-types.ts create mode 100644 web/src/components/projects/agent-config-utils.ts create mode 100644 web/src/components/projects/integration-alerting-tab.tsx create mode 100644 web/src/components/projects/integration-scm-tab.tsx diff --git a/tests/unit/web/agent-config-utils.test.ts b/tests/unit/web/agent-config-utils.test.ts new file mode 100644 index 00000000..138d3680 --- /dev/null +++ b/tests/unit/web/agent-config-utils.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import type { ResolvedTrigger } from '../../../src/api/routers/_shared/triggerTypes.js'; +import { + countActiveTriggers, + engineHasCredentials, +} from '../../../web/src/components/projects/agent-config-utils.js'; + +// ============================================================================ +// engineHasCredentials +// ============================================================================ + +describe('engineHasCredentials', () => { + it('returns true for unknown engines (conservative assumption)', () => { + const keys = new Set(); + expect(engineHasCredentials('unknown-engine', keys)).toBe(true); + }); + + it('returns false for codex when no credential keys are configured', () => { + const keys = new Set(); + expect(engineHasCredentials('codex', keys)).toBe(false); + }); + + it('returns true for codex when OPENAI_API_KEY is configured', () => { + const keys = new Set(['OPENAI_API_KEY']); + expect(engineHasCredentials('codex', keys)).toBe(true); + }); + + it('returns true for codex when CODEX_AUTH_JSON is configured', () => { + const keys = new Set(['CODEX_AUTH_JSON']); + expect(engineHasCredentials('codex', keys)).toBe(true); + }); + + it('returns false for claude-code when no credential keys are configured', () => { + const keys = new Set(); + expect(engineHasCredentials('claude-code', keys)).toBe(false); + }); + + it('returns true for claude-code when ANTHROPIC_API_KEY is configured', () => { + const keys = new Set(['ANTHROPIC_API_KEY']); + expect(engineHasCredentials('claude-code', keys)).toBe(true); + }); + + it('returns true for claude-code when CLAUDE_CODE_OAUTH_TOKEN is configured', () => { + const keys = new Set(['CLAUDE_CODE_OAUTH_TOKEN']); + expect(engineHasCredentials('claude-code', keys)).toBe(true); + }); + + it('returns false for opencode when no credential keys are configured', () => { + const keys = new Set(); + expect(engineHasCredentials('opencode', keys)).toBe(false); + }); + + it('returns true for opencode when OPENAI_API_KEY is configured', () => { + const keys = new Set(['OPENAI_API_KEY']); + expect(engineHasCredentials('opencode', keys)).toBe(true); + }); + + it('returns true for opencode when OPENROUTER_API_KEY is configured', () => { + const keys = new Set(['OPENROUTER_API_KEY']); + expect(engineHasCredentials('opencode', keys)).toBe(true); + }); + + it('returns false for llmist when no credential keys are configured', () => { + const keys = new Set(); + expect(engineHasCredentials('llmist', keys)).toBe(false); + }); + + it('returns true for llmist when OPENROUTER_API_KEY is configured', () => { + const keys = new Set(['OPENROUTER_API_KEY']); + expect(engineHasCredentials('llmist', keys)).toBe(true); + }); + + it('ignores unrelated keys', () => { + const keys = new Set(['SOME_OTHER_KEY', 'UNRELATED_KEY']); + expect(engineHasCredentials('claude-code', keys)).toBe(false); + }); +}); + +// ============================================================================ +// countActiveTriggers +// ============================================================================ + +describe('countActiveTriggers', () => { + const integrations = { pm: 'trello', scm: 'github' }; + + function makeTrigger(event: string, enabled: boolean, providers?: string[]): ResolvedTrigger { + return { + event, + enabled, + providers: providers ?? [], + label: event, + parameters: [], + parameterValues: {}, + } as ResolvedTrigger; + } + + it('returns 0 when there are no triggers', () => { + expect(countActiveTriggers([], integrations)).toBe(0); + }); + + it('counts only enabled triggers', () => { + const triggers = [ + makeTrigger('pm:card-created', true), + makeTrigger('pm:card-moved', false), + makeTrigger('scm:pr-opened', true), + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(2); + }); + + it('counts triggers without provider restrictions normally', () => { + const triggers = [ + makeTrigger('internal:run-complete', true), + makeTrigger('internal:task-failed', true), + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(2); + }); + + it('filters out enabled triggers whose provider does not match active integration', () => { + const triggers = [ + // This trigger is enabled but requires 'jira' — active pm is 'trello' + makeTrigger('pm:issue-created', true, ['jira']), + // This trigger requires 'trello' — active pm is 'trello' + makeTrigger('pm:card-created', true, ['trello']), + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(1); + }); + + it('includes enabled triggers whose provider matches active integration', () => { + const triggers = [ + makeTrigger('scm:pr-opened', true, ['github']), + makeTrigger('scm:pr-merged', true, ['github']), + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(2); + }); + + it('returns 0 for all disabled triggers even if provider matches', () => { + const triggers = [ + makeTrigger('pm:card-created', false, ['trello']), + makeTrigger('scm:pr-opened', false, ['github']), + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(0); + }); + + it('handles null integrations gracefully', () => { + const noIntegrations = { pm: null, scm: null }; + const triggers = [ + // provider restriction — active integration is null, so no match + makeTrigger('pm:card-created', true, ['trello']), + // no provider restriction — always included + makeTrigger('internal:run-complete', true), + ]; + expect(countActiveTriggers(triggers, noIntegrations)).toBe(1); + }); + + it('counts mixed enabled/disabled triggers with provider filtering', () => { + const triggers = [ + makeTrigger('pm:card-created', true, ['trello']), // enabled, provider matches + makeTrigger('pm:card-moved', false, ['trello']), // disabled, skipped + makeTrigger('pm:issue-created', true, ['jira']), // enabled, wrong provider + makeTrigger('internal:run-complete', true), // enabled, no restriction + ]; + expect(countActiveTriggers(triggers, integrations)).toBe(2); + }); +}); diff --git a/web/src/components/projects/agent-config-detail.tsx b/web/src/components/projects/agent-config-detail.tsx new file mode 100644 index 00000000..d5b837fc --- /dev/null +++ b/web/src/components/projects/agent-config-detail.tsx @@ -0,0 +1,414 @@ +/** + * Agent detail view components: DefinitionAgentSection and AgentDetailView. + * Renders the tabbed detail panel (Engine / Prompts / Triggers) for a single agent. + */ +import { ArrowLeft } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { EngineSettingsFields } from '@/components/settings/engine-settings-fields.js'; +import { ModelField } from '@/components/settings/model-field.js'; +import { + DefinitionTriggerToggles, + type ResolvedTrigger, +} from '@/components/shared/definition-trigger-toggles.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.js'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js'; +import { AGENT_LABELS, CATEGORY_LABELS } from '@/lib/trigger-agent-mapping.js'; +import type { AgentDetailViewProps, DefinitionAgentSectionProps } from './agent-config-types.js'; +import { AgentPromptOverrides } from './agent-prompt-overrides.js'; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tabbed detail panel managing Engine/Prompts/Triggers tabs with per-tab state, mutations, and trigger category grouping +function DefinitionAgentSection({ + agentType, + projectId, + config, + triggers, + integrations, + engines, + isSaving, + onSaveConfig, + saveSuccessNonce, + onDeleteConfig, + onTriggerToggle, + onTriggerParamChange, + projectModel, + projectEngine, + projectMaxIterations, + systemDefaults, +}: DefinitionAgentSectionProps) { + 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 — engine fields + 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() ?? ''); + const [engineSettings, setEngineSettings] = useState< + Record> | undefined + >(config?.agentEngineSettings ?? undefined); + + // Local form state — prompt fields (initialized by AgentPromptOverrides component) + const [systemPrompt, setSystemPrompt] = useState(config?.systemPrompt ?? ''); + const [taskPrompt, setTaskPrompt] = useState(config?.taskPrompt ?? ''); + // Track whether the user explicitly cleared a prompt override so we can send null on save + // instead of the fallback display text (which would create a duplicate "custom" override). + const [systemPromptCleared, setSystemPromptCleared] = useState(false); + const [taskPromptCleared, setTaskPromptCleared] = useState(false); + + const effectiveEngineId = agentEngine || ''; + const effectiveEngine = engines.find((engine) => engine.id === effectiveEngineId); + + // Resolved inherited engine — project override or system default + const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? 'claude-code'; + // Per-field engine defaults for the EngineSettingsFields component + const engineDefaults = + systemDefaults && effectiveEngineId + ? systemDefaults.engineSettings[effectiveEngineId] + : undefined; + + // Resolved inherited model and iterations (walk the chain: project → system) + const inheritedModel = projectModel ?? systemDefaults?.model; + const inheritedMaxIterations = projectMaxIterations ?? systemDefaults?.maxIterations; + + // 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() ?? ''); + setEngineSettings(config?.agentEngineSettings ?? undefined); + setSystemPrompt(config?.systemPrompt ?? ''); + setTaskPrompt(config?.taskPrompt ?? ''); + setSystemPromptCleared(false); + setTaskPromptCleared(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); + } + 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 = { + pm: [], + scm: [], + internal: [], + }; + + for (const trigger of triggers) { + // Extract category from event (e.g., "pm:card-moved" -> "pm") + const [category] = trigger.event.split(':'); + if (category in groups) { + // Filter by provider if the trigger has provider restrictions + if (trigger.providers && trigger.providers.length > 0) { + const activeProvider = integrations[category as keyof typeof integrations]; + const matchesProvider = trigger.providers.some((p) => p === activeProvider); + if (!matchesProvider) continue; + } + groups[category].push(trigger); + } + } + + return groups; + }, [triggers, integrations]); + + const hasTriggers = + triggersByCategory.pm.length > 0 || + triggersByCategory.scm.length > 0 || + triggersByCategory.internal.length > 0; + + const handleSave = () => { + onSaveConfig(agentType, config?.id ?? null, { + model, + maxIterations, + agentEngine, + maxConcurrency, + engineSettings, + systemPrompt, + taskPrompt, + systemPromptCleared, + taskPromptCleared, + }); + }; + + const handleCancel = () => { + setModel(config?.model ?? ''); + setMaxIterations(config?.maxIterations?.toString() ?? ''); + setAgentEngine(config?.agentEngine ?? ''); + setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); + setEngineSettings(config?.agentEngineSettings ?? undefined); + setSystemPrompt(config?.systemPrompt ?? ''); + setTaskPrompt(config?.taskPrompt ?? ''); + setSystemPromptCleared(false); + setTaskPromptCleared(false); + }; + + const handleDelete = () => { + if (config && window.confirm('Delete this agent config?')) { + onDeleteConfig(config.id); + } + }; + + return ( +
+ + + Engine + Prompts + Triggers + + + {/* Engine Tab */} + +
+ + +
+
+ + +
+ {effectiveEngine && ( + + )} +
+
+ + setMaxIterations(e.target.value)} + placeholder={ + inheritedMaxIterations !== undefined + ? `${inheritedMaxIterations} (inherited)` + : 'Optional' + } + /> +
+
+ + setMaxConcurrency(e.target.value)} + placeholder="Optional" + /> +
+
+
+ + {/* Prompts Tab */} + + { + setSystemPrompt(v); + // User is editing manually — cancel any pending clear + setSystemPromptCleared(false); + }} + taskPrompt={taskPrompt} + onTaskPromptChange={(v) => { + setTaskPrompt(v); + // User is editing manually — cancel any pending clear + setTaskPromptCleared(false); + }} + onSystemPromptClear={() => setSystemPromptCleared(true)} + onTaskPromptClear={() => setTaskPromptCleared(true)} + /> + + + {/* Triggers Tab */} + + {(['pm', 'scm', 'internal'] as const).map((category) => { + const categoryTriggers = triggersByCategory[category]; + if (categoryTriggers.length === 0) return null; + + return ( +
+

+ {CATEGORY_LABELS[category] ?? category} Triggers +

+ onTriggerToggle(agentType, event, enabled)} + onParamChange={(event, params) => { + // Find the current trigger to get its enabled state + const currentTrigger = categoryTriggers.find((t) => t.event === event); + onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); + }} + idPrefix={`${agentType}-${category}`} + /> +
+ ); + })} + + {!hasTriggers && ( +

+ No trigger configuration for this agent. +

+ )} +
+
+ + {/* Footer actions — outside tabs, applies globally */} +
+
+ + + {saved && Saved} +
+ {config && ( + + )} +
+
+ ); +} + +export function AgentDetailView({ + agentType, + projectId, + config, + triggers, + integrations, + engines, + isSaving, + onSaveConfig, + saveSuccessNonce, + onDeleteConfig, + onTriggerToggle, + onTriggerParamChange, + onBack, + projectModel, + projectEngine, + projectMaxIterations, + systemDefaults, +}: AgentDetailViewProps) { + const label = (AGENT_LABELS as Record)[agentType] ?? agentType; + + return ( +
+
+ +
+
+

{label}

+

+ Configure model, engine, and trigger settings for the {label} agent. +

+
+ { + onDeleteConfig(id); + onBack(); + }} + onTriggerToggle={onTriggerToggle} + onTriggerParamChange={onTriggerParamChange} + projectModel={projectModel} + projectEngine={projectEngine} + projectMaxIterations={projectMaxIterations} + systemDefaults={systemDefaults} + /> +
+ ); +} diff --git a/web/src/components/projects/agent-config-list.tsx b/web/src/components/projects/agent-config-list.tsx new file mode 100644 index 00000000..bb5c1d72 --- /dev/null +++ b/web/src/components/projects/agent-config-list.tsx @@ -0,0 +1,275 @@ +/** + * Agent list view components: AgentRow and AgentListView. + * Renders the table of configured agents and the list of available agents to enable. + */ +import { AlertTriangle, ChevronRight, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog.js'; +import { Badge } from '@/components/ui/badge.js'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table.js'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip.js'; +import { AGENT_LABELS } from '@/lib/trigger-agent-mapping.js'; +import type { AgentListViewProps, AgentRowProps } from './agent-config-types.js'; +import { countActiveTriggers, engineHasCredentials } from './agent-config-utils.js'; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: table row with multiple computed display values (model, engine, trigger count) and layered inheritance fallbacks +export function AgentRow({ + type, + config, + triggers, + integrations, + onSelect, + onDeleteRequest, + projectModel, + projectEngine, + systemDefaults, + configuredCredentialKeys, +}: AgentRowProps) { + const label = (AGENT_LABELS as Record)[type] ?? type; + const activeTriggerCount = countActiveTriggers(triggers, integrations); + const modelInfo = config?.model ?? null; + const engineInfo = config?.agentEngine ?? null; + const hasCustomEngineSettings = + config?.agentEngineSettings != null && Object.keys(config.agentEngineSettings).length > 0; + + // Fallback display: show inherited model/engine when agent has no specific override + const inheritedModel = projectModel ?? systemDefaults?.model ?? null; + const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? null; + const displayModel = modelInfo ?? (inheritedModel ? `${inheritedModel} (inherited)` : null); + const displayEngine = engineInfo ?? (inheritedEngine ? `${inheritedEngine} (inherited)` : null); + + // Check if the agent's effective engine has credentials configured + // Only check when there is an explicit agent-level engine override + const agentEngineId = config?.agentEngine ?? null; + const hasMissingCredentials = + agentEngineId !== null && !engineHasCredentials(agentEngineId, configuredCredentialKeys); + + return ( + onSelect(type)}> + {label} + + {activeTriggerCount === 0 ? ( + + Inactive + + ) : config ? ( +
+ + Configured + + {hasMissingCredentials && ( + + + + + Missing credentials + + + + This agent uses the {agentEngineId} engine but no credentials are configured for + it. Configure credentials on the Harness tab. + + + )} +
+ ) : ( + + Default + + )} +
+ + {displayModel || displayEngine ? ( + + {displayEngine && {displayEngine}} + {displayEngine && displayModel && · } + {displayModel && {displayModel}} + {hasCustomEngineSettings && ( + + Custom settings + + )} + + ) : ( + + )} + + + {activeTriggerCount > 0 ? ( + {activeTriggerCount} active + ) : ( + + + + + None + + + + No triggers configured — this agent won't process any events + + + )} + + +
+ {config && ( + + )} + +
+
+
+ ); +} + +export function AgentListView({ + enabledAgentTypes, + availableAgentTypes, + configByAgent, + triggersByAgent, + integrations, + onSelect, + onDelete, + onEnable, + isDeleting, + isEnabling, + projectModel, + projectEngine, + systemDefaults, + configuredCredentialKeys, +}: AgentListViewProps) { + const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); + + return ( + <> + {enabledAgentTypes.length === 0 ? ( +
+ No agents enabled. Enable agents below to start processing. +
+ ) : ( +
+ + + + + Agent + Status + Engine / Model + Active Triggers + + + + + {enabledAgentTypes.map((type) => ( + setDeleteTarget({ id, label })} + projectModel={projectModel} + projectEngine={projectEngine} + systemDefaults={systemDefaults} + configuredCredentialKeys={configuredCredentialKeys} + /> + ))} + +
+
+
+ )} + + {availableAgentTypes.length > 0 && ( +
+

Available Agents

+
+ {availableAgentTypes.map((agentType) => { + const label = + (AGENT_LABELS as Record)[agentType] ?? agentType; + return ( +
+ {label} + +
+ ); + })} +
+
+ )} + + !open && setDeleteTarget(null)}> + + + Delete Agent Config + + Are you sure you want to delete the config for {deleteTarget?.label}? + The agent will be disabled and no longer process any events. This action cannot be + undone. + + + + Cancel + { + if (deleteTarget) { + onDelete(deleteTarget.id); + setDeleteTarget(null); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + + ); +} diff --git a/web/src/components/projects/agent-config-types.ts b/web/src/components/projects/agent-config-types.ts new file mode 100644 index 00000000..ac8d4ebd --- /dev/null +++ b/web/src/components/projects/agent-config-types.ts @@ -0,0 +1,167 @@ +/** + * Shared types for the agent configuration components. + * + * Extracted from project-agent-configs.tsx so each sub-module can import + * only what it needs without circular dependencies. + */ + +import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; +import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; + +export interface AgentConfig { + id: number; + agentType: string; + model: string | null; + maxIterations: number | null; + agentEngine: string | null; + agentEngineSettings: Record> | null; + maxConcurrency: number | null; + systemPrompt: string | null; + taskPrompt: string | null; +} + +interface EngineSettingFieldOption { + value: string; + label: string; +} + +export type EngineSettingField = + | { + key: string; + label: string; + type: 'select'; + description?: string; + options: EngineSettingFieldOption[]; + } + | { key: string; label: string; type: 'boolean'; description?: string } + | { + key: string; + label: string; + type: 'number'; + description?: string; + min?: number; + max?: number; + step?: number; + }; + +export interface Engine { + id: string; + label: string; + settings?: { + title?: string; + description?: string; + fields: EngineSettingField[]; + }; +} + +export interface SaveConfigValues { + model: string; + maxIterations: string; + agentEngine: string; + maxConcurrency: string; + engineSettings: Record> | undefined; + systemPrompt: string; + taskPrompt: string; + /** True when the user explicitly cleared the system prompt override (send null, not the fallback text). */ + systemPromptCleared: boolean; + /** True when the user explicitly cleared the task prompt override (send null, not the fallback text). */ + taskPromptCleared: boolean; +} + +export interface SystemDefaults { + model: string; + maxIterations: number; + agentEngine: string; + engineSettings: Record>; +} + +export interface DefinitionAgentSectionProps { + agentType: string; + projectId: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { + pm: string | null; + scm: string | null; + }; + 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: ( + agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, + ) => void; + /** Project-level model (null = use system default). */ + projectModel: string | null; + /** Project-level engine (null = use system default). */ + projectEngine: string | null; + /** Project-level maxIterations (null = use system default). */ + projectMaxIterations: number | null; + /** System-level defaults from the backend. */ + systemDefaults: SystemDefaults | undefined; +} + +export interface AgentRowProps { + type: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { pm: string | null; scm: string | null }; + onSelect: (agentType: string) => void; + onDeleteRequest: (id: number, label: string) => void; + /** Project-level model to show as "inherited" when agent has no override. */ + projectModel: string | null; + /** Project-level engine to show as "inherited" when agent has no override. */ + projectEngine: string | null; + /** System-level defaults. */ + systemDefaults: SystemDefaults | undefined; + /** Set of credential env-var keys that are configured for this project. */ + configuredCredentialKeys: Set; +} + +export interface AgentListViewProps { + enabledAgentTypes: string[]; + availableAgentTypes: string[]; + configByAgent: Map; + triggersByAgent: Map; + integrations: { pm: string | null; scm: string | null }; + onSelect: (agentType: string) => void; + onDelete: (id: number) => void; + onEnable: (agentType: string) => void; + isDeleting: boolean; + isEnabling: boolean; + projectModel: string | null; + projectEngine: string | null; + systemDefaults: SystemDefaults | undefined; + /** Set of credential env-var keys that are configured for this project. */ + configuredCredentialKeys: Set; +} + +export interface AgentDetailViewProps { + agentType: string; + projectId: string; + config: AgentConfig | null; + triggers: ResolvedTrigger[]; + integrations: { pm: string | null; scm: string | null }; + 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: ( + agentType: string, + event: string, + parameters: Record, + currentEnabled: boolean, + ) => void; + onBack: () => void; + projectModel: string | null; + projectEngine: string | null; + projectMaxIterations: number | null; + systemDefaults: SystemDefaults | undefined; +} diff --git a/web/src/components/projects/agent-config-utils.ts b/web/src/components/projects/agent-config-utils.ts new file mode 100644 index 00000000..80296d2c --- /dev/null +++ b/web/src/components/projects/agent-config-utils.ts @@ -0,0 +1,40 @@ +/** + * Pure utility functions for agent configuration components. + * These functions are free of React and UI dependencies — easy to unit-test. + */ + +import type { ResolvedTrigger } from '../shared/definition-trigger-toggles.js'; +import { engineCredentialKeys } from './engine-secrets.js'; + +/** + * Returns true when the given engine has at least one credential key configured. + * Derived from ENGINE_SECRETS in engine-secrets.ts — no separate mapping to maintain. + * If the engine is not in the map, we conservatively assume credentials are present. + */ +export function engineHasCredentials( + engineId: string, + configuredCredentialKeys: Set, +): boolean { + const requiredKeys = engineCredentialKeys[engineId]; + if (!requiredKeys) return true; // Unknown engine — assume ok + return requiredKeys.some((key) => configuredCredentialKeys.has(key)); +} + +/** + * Counts the number of active triggers for an agent, filtering by provider + * when the trigger has provider restrictions. + */ +export function countActiveTriggers( + triggers: ResolvedTrigger[], + integrations: { pm: string | null; scm: string | null }, +): number { + return triggers.filter((t) => { + if (!t.enabled) return false; + const [category] = t.event.split(':'); + if (t.providers && t.providers.length > 0) { + const activeProvider = integrations[category as keyof typeof integrations]; + return t.providers.some((p) => p === activeProvider); + } + return true; + }).length; +} diff --git a/web/src/components/projects/integration-alerting-tab.tsx b/web/src/components/projects/integration-alerting-tab.tsx new file mode 100644 index 00000000..d36745ed --- /dev/null +++ b/web/src/components/projects/integration-alerting-tab.tsx @@ -0,0 +1,198 @@ +/** + * Alerting (Sentry) integration tab component. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { CopyButton } from './integration-scm-tab.js'; +import { ProjectSecretField } from './project-secret-field.js'; + +// ============================================================================ +// Alerting Tab (Sentry) +// ============================================================================ + +interface AlertingTabProps { + projectId: string; + alertingIntegration?: Record; +} + +export function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { + const queryClient = useQueryClient(); + + const existingConfig = (alertingIntegration?.config as Record) ?? {}; + const [organizationSlug, setOrganizationSlug] = useState( + (existingConfig.organizationSlug as string) ?? '', + ); + + const [verifyResult, setVerifyResult] = useState<{ + id: string; + name: string; + slug: string; + } | null>(null); + const [verifyError, setVerifyError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const sentryWebhookUrl = callbackBaseUrl + ? `${callbackBaseUrl}/sentry/webhook/${projectId}` + : `/sentry/webhook/${projectId}`; + + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + const credentials = credentialsQuery.data ?? []; + const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); + const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); + + const handleVerify = async (rawToken: string) => { + if (!rawToken) { + setVerifyError('Enter the API token value to verify it'); + return; + } + if (!organizationSlug) { + setVerifyError('Enter the organization slug to verify it'); + return; + } + setIsVerifying(true); + setVerifyError(null); + setVerifyResult(null); + try { + const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ + apiToken: rawToken, + organizationSlug, + }); + setVerifyResult(result); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : String(err)); + } finally { + setIsVerifying(false); + } + }; + + const saveMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'alerting', + provider: 'sentry', + config: { organizationSlug }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + return trpcClient.projects.integrations.delete.mutate({ + projectId, + category: 'alerting', + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+ {/* Organization Slug */} +
+ +

+ Your Sentry organization slug (found in your Sentry URL:{' '} + sentry.io/organizations/<slug>/). +

+ setOrganizationSlug(e.target.value)} + placeholder="my-organization" + /> +
+ +
+ + {/* Credentials */} +
+ + + +
+ +
+ + {/* Sentry Webhook URL */} +
+ +

+ Configure this URL in your Sentry project's webhook settings to receive alerts. +

+
+ {sentryWebhookUrl} + +
+
+ +
+ + {/* Save / Delete */} +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} + {alertingIntegration && ( + + )} + {deleteMutation.isError && ( + {deleteMutation.error.message} + )} +
+
+ ); +} diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index cc69f51e..328bed69 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -1,618 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - AlertCircle, - AlertTriangle, - Check, - Clipboard, - ExternalLink, - Info, - Loader2, - RefreshCw, - Trash2, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; -import { API_URL } from '@/lib/api.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { trpc } from '@/lib/trpc.js'; +import { AlertingTab } from './integration-alerting-tab.js'; +import { SCMTab } from './integration-scm-tab.js'; import { PMWizard } from './pm-wizard.js'; -import { ProjectSecretField } from './project-secret-field.js'; type IntegrationCategory = 'pm' | 'scm' | 'alerting'; -// ============================================================================ -// GitHub Credential Slots (replaces the old CredentialSelector dropdowns) -// ============================================================================ - -function GitHubCredentialSlots({ projectId }: { projectId: string }) { - const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); - - const [verifiedLogins, setVerifiedLogins] = useState>({}); - const [verifyErrors, setVerifyErrors] = useState>({}); - const [verifyingRoles, setVerifyingRoles] = useState>({}); - - const credentials = credentialsQuery.data ?? []; - const implementerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_IMPLEMENTER'); - const reviewerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_REVIEWER'); - - const handleVerify = async (role: string, rawValue: string) => { - // If no new value entered, we can't verify (we never return plaintext to browser) - if (!rawValue) { - setVerifyErrors((prev) => ({ - ...prev, - [role]: 'Enter the token value to verify it', - })); - return; - } - setVerifyingRoles((prev) => ({ ...prev, [role]: true })); - try { - const result = await trpcClient.integrationsDiscovery.verifyGithubToken.mutate({ - token: rawValue, - }); - setVerifiedLogins((prev) => ({ ...prev, [role]: result.login })); - setVerifyErrors((prev) => ({ ...prev, [role]: null })); - } catch (err) { - setVerifiedLogins((prev) => ({ ...prev, [role]: null })); - setVerifyErrors((prev) => ({ - ...prev, - [role]: err instanceof Error ? err.message : String(err), - })); - } finally { - setVerifyingRoles((prev) => ({ ...prev, [role]: false })); - } - }; - - return ( -
- - handleVerify('implementer', val)} - isVerifying={verifyingRoles.implementer} - verifyError={verifyErrors.implementer} - /> - handleVerify('reviewer', val)} - isVerifying={verifyingRoles.reviewer} - verifyError={verifyErrors.reviewer} - /> -
- ); -} - -// ============================================================================ -// GitHub Webhook Management -// ============================================================================ - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - const handleCopy = async () => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( - - ); -} - -function GitHubWebhookSection({ projectId }: { projectId: string }) { - const queryClient = useQueryClient(); - - const callbackBaseUrl = - API_URL || - (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); - - const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); - - const createGithubWebhookMutation = useMutation({ - mutationFn: () => - trpcClient.webhooks.create.mutate({ - projectId, - callbackBaseUrl, - githubOnly: true, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const deleteGithubWebhookMutation = useMutation({ - mutationFn: (deleteCallbackBaseUrl: string) => - trpcClient.webhooks.delete.mutate({ - projectId, - callbackBaseUrl: deleteCallbackBaseUrl, - githubOnly: true, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const activeGithubWebhooks = (webhooksQuery.data?.github ?? []).map((w) => ({ - id: String(w.id), - url: w.config.url ?? '', - active: w.active, - })); - - const webhookCallbackUrl = callbackBaseUrl - ? `${callbackBaseUrl}/github/webhook` - : '/github/webhook'; - const githubCurlCommand = [ - 'curl -X POST "https://api.github.com/repos///hooks" \\', - ' -H "Authorization: Bearer " \\', - ' -H "Content-Type: application/json" \\', - " -d '{", - ' "name": "web",', - ' "active": true,', - ' "events": ["push", "pull_request", "check_suite", "pull_request_review"],', - ' "config": {', - ` "url": "${webhookCallbackUrl}",`, - ' "content_type": "json"', - ' }', - " }'", - ].join('\n'); - - return ( -
-
- -

- Manage GitHub webhooks for receiving push events, PR updates, and CI status notifications. -

-
- - {/* GitHub-specific error */} - {webhooksQuery.data?.errors?.github && ( -
- -
- GitHub - - : {String(webhooksQuery.data.errors.github)} - -
- -
- )} - - {/* Active webhooks list */} - {webhooksQuery.isLoading ? ( -
- Loading webhooks... -
- ) : activeGithubWebhooks.length > 0 ? ( -
- {activeGithubWebhooks.map((w) => ( -
-
- - {w.url} -
- -
- ))} -
- ) : ( -
- - No GitHub webhooks configured for this project. -
- )} - - {/* curl instructions for manual GitHub webhook creation (collapsible) */} -
- - -

- Manual webhook creation (alternative: if the button below doesn't work) -

-
-
-

- Use the following curl command to create the GitHub webhook manually. Requires a token - with admin:repo_hook scope. -

-
-
- -
-
-							{githubCurlCommand}
-						
-
-
-
- - {/* Create webhook button */} -
- - {createGithubWebhookMutation.isError && ( -

{createGithubWebhookMutation.error.message}

- )} - {createGithubWebhookMutation.isSuccess && ( -

- GitHub webhook created successfully. -

- )} -
-
- ); -} - -// ============================================================================ -// SCM Tab (GitHub) -// ============================================================================ - -interface SCMTabProject { - repo?: string | null; - baseBranch?: string | null; - branchPrefix?: string | null; -} - -function SCMTab({ projectId, project }: { projectId: string; project?: SCMTabProject }) { - const queryClient = useQueryClient(); - - // Project-level SCM fields - const [repo, setRepo] = useState(project?.repo ?? ''); - const [baseBranch, setBaseBranch] = useState(project?.baseBranch ?? 'main'); - const [branchPrefix, setBranchPrefix] = useState(project?.branchPrefix ?? 'feature/'); - - useEffect(() => { - setRepo(project?.repo ?? ''); - setBaseBranch(project?.baseBranch ?? 'main'); - setBranchPrefix(project?.branchPrefix ?? 'feature/'); - }, [project?.repo, project?.baseBranch, project?.branchPrefix]); - - const saveMutation = useMutation({ - mutationFn: async () => { - // Save project-level SCM fields - await trpcClient.projects.update.mutate({ - id: projectId, - repo: repo || undefined, - baseBranch, - branchPrefix, - }); - - // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab - const result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'scm', - provider: 'github', - config: {}, - }); - - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.getById.queryOptions({ id: projectId }).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.listFull.queryOptions().queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - return ( -
- {/* Repository Settings */} -
- -
- - setRepo(e.target.value)} - placeholder="owner/repo" - /> -
-
-
- - setBaseBranch(e.target.value)} - placeholder="main" - /> -
-
- - setBranchPrefix(e.target.value)} - placeholder="feature/" - /> -
-
-
- -
- -

- CASCADE uses two separate GitHub bot accounts to prevent feedback loops. The{' '} - implementer writes code and creates PRs. The reviewer{' '} - reviews PRs and can approve or request changes. -

- - - -

- Trigger configuration has moved to the Agents tab. -

- -
- - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} -
- -
- - -
- ); -} - -// ============================================================================ -// Alerting Tab (Sentry) -// ============================================================================ - -interface AlertingTabProps { - projectId: string; - alertingIntegration?: Record; -} - -function AlertingTab({ projectId, alertingIntegration }: AlertingTabProps) { - const queryClient = useQueryClient(); - - const existingConfig = (alertingIntegration?.config as Record) ?? {}; - const [organizationSlug, setOrganizationSlug] = useState( - (existingConfig.organizationSlug as string) ?? '', - ); - - const [verifyResult, setVerifyResult] = useState<{ - id: string; - name: string; - slug: string; - } | null>(null); - const [verifyError, setVerifyError] = useState(null); - const [isVerifying, setIsVerifying] = useState(false); - - const callbackBaseUrl = - API_URL || - (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); - - const sentryWebhookUrl = callbackBaseUrl - ? `${callbackBaseUrl}/sentry/webhook/${projectId}` - : `/sentry/webhook/${projectId}`; - - const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); - const credentials = credentialsQuery.data ?? []; - const apiTokenCred = credentials.find((c) => c.envVarKey === 'SENTRY_API_TOKEN'); - const webhookSecretCred = credentials.find((c) => c.envVarKey === 'SENTRY_WEBHOOK_SECRET'); - - const handleVerify = async (rawToken: string) => { - if (!rawToken) { - setVerifyError('Enter the API token value to verify it'); - return; - } - if (!organizationSlug) { - setVerifyError('Enter the organization slug to verify it'); - return; - } - setIsVerifying(true); - setVerifyError(null); - setVerifyResult(null); - try { - const result = await trpcClient.integrationsDiscovery.verifySentry.mutate({ - apiToken: rawToken, - organizationSlug, - }); - setVerifyResult(result); - } catch (err) { - setVerifyError(err instanceof Error ? err.message : String(err)); - } finally { - setIsVerifying(false); - } - }; - - const saveMutation = useMutation({ - mutationFn: async () => { - return trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'alerting', - provider: 'sentry', - config: { organizationSlug }, - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: async () => { - return trpcClient.projects.integrations.delete.mutate({ - projectId, - category: 'alerting', - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - return ( -
- {/* Organization Slug */} -
- -

- Your Sentry organization slug (found in your Sentry URL:{' '} - sentry.io/organizations/<slug>/). -

- setOrganizationSlug(e.target.value)} - placeholder="my-organization" - /> -
- -
- - {/* Credentials */} -
- - - -
- -
- - {/* Sentry Webhook URL */} -
- -

- Configure this URL in your Sentry project's webhook settings to receive alerts. -

-
- {sentryWebhookUrl} - -
-
- -
- - {/* Save / Delete */} -
- - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} - {alertingIntegration && ( - - )} - {deleteMutation.isError && ( - {deleteMutation.error.message} - )} -
-
- ); -} - // ============================================================================ // Helpers // ============================================================================ diff --git a/web/src/components/projects/integration-scm-tab.tsx b/web/src/components/projects/integration-scm-tab.tsx new file mode 100644 index 00000000..81233f54 --- /dev/null +++ b/web/src/components/projects/integration-scm-tab.tsx @@ -0,0 +1,434 @@ +/** + * SCM (GitHub) integration tab components. + * Contains: CopyButton, GitHubCredentialSlots, GitHubWebhookSection, SCMTab. + * CopyButton is co-located here and also exported for use by AlertingTab. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AlertCircle, + AlertTriangle, + Check, + Clipboard, + ExternalLink, + Info, + Loader2, + RefreshCw, + Trash2, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { ProjectSecretField } from './project-secret-field.js'; + +// ============================================================================ +// CopyButton (shared with AlertingTab) +// ============================================================================ + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( + + ); +} + +// ============================================================================ +// GitHub Credential Slots (replaces the old CredentialSelector dropdowns) +// ============================================================================ + +function GitHubCredentialSlots({ projectId }: { projectId: string }) { + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + + const [verifiedLogins, setVerifiedLogins] = useState>({}); + const [verifyErrors, setVerifyErrors] = useState>({}); + const [verifyingRoles, setVerifyingRoles] = useState>({}); + + const credentials = credentialsQuery.data ?? []; + const implementerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_IMPLEMENTER'); + const reviewerCred = credentials.find((c) => c.envVarKey === 'GITHUB_TOKEN_REVIEWER'); + + const handleVerify = async (role: string, rawValue: string) => { + // If no new value entered, we can't verify (we never return plaintext to browser) + if (!rawValue) { + setVerifyErrors((prev) => ({ + ...prev, + [role]: 'Enter the token value to verify it', + })); + return; + } + setVerifyingRoles((prev) => ({ ...prev, [role]: true })); + try { + const result = await trpcClient.integrationsDiscovery.verifyGithubToken.mutate({ + token: rawValue, + }); + setVerifiedLogins((prev) => ({ ...prev, [role]: result.login })); + setVerifyErrors((prev) => ({ ...prev, [role]: null })); + } catch (err) { + setVerifiedLogins((prev) => ({ ...prev, [role]: null })); + setVerifyErrors((prev) => ({ + ...prev, + [role]: err instanceof Error ? err.message : String(err), + })); + } finally { + setVerifyingRoles((prev) => ({ ...prev, [role]: false })); + } + }; + + return ( +
+ + handleVerify('implementer', val)} + isVerifying={verifyingRoles.implementer} + verifyError={verifyErrors.implementer} + /> + handleVerify('reviewer', val)} + isVerifying={verifyingRoles.reviewer} + verifyError={verifyErrors.reviewer} + /> +
+ ); +} + +// ============================================================================ +// GitHub Webhook Management +// ============================================================================ + +function GitHubWebhookSection({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); + + const createGithubWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId, + callbackBaseUrl, + githubOnly: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteGithubWebhookMutation = useMutation({ + mutationFn: (deleteCallbackBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId, + callbackBaseUrl: deleteCallbackBaseUrl, + githubOnly: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const activeGithubWebhooks = (webhooksQuery.data?.github ?? []).map((w) => ({ + id: String(w.id), + url: w.config.url ?? '', + active: w.active, + })); + + const webhookCallbackUrl = callbackBaseUrl + ? `${callbackBaseUrl}/github/webhook` + : '/github/webhook'; + const githubCurlCommand = [ + 'curl -X POST "https://api.github.com/repos///hooks" \\', + ' -H "Authorization: Bearer " \\', + ' -H "Content-Type: application/json" \\', + " -d '{", + ' "name": "web",', + ' "active": true,', + ' "events": ["push", "pull_request", "check_suite", "pull_request_review"],', + ' "config": {', + ` "url": "${webhookCallbackUrl}",`, + ' "content_type": "json"', + ' }', + " }'", + ].join('\n'); + + return ( +
+
+ +

+ Manage GitHub webhooks for receiving push events, PR updates, and CI status notifications. +

+
+ + {/* GitHub-specific error */} + {webhooksQuery.data?.errors?.github && ( +
+ +
+ GitHub + + : {String(webhooksQuery.data.errors.github)} + +
+ +
+ )} + + {/* Active webhooks list */} + {webhooksQuery.isLoading ? ( +
+ Loading webhooks... +
+ ) : activeGithubWebhooks.length > 0 ? ( +
+ {activeGithubWebhooks.map((w) => ( +
+
+ + {w.url} +
+ +
+ ))} +
+ ) : ( +
+ + No GitHub webhooks configured for this project. +
+ )} + + {/* curl instructions for manual GitHub webhook creation (collapsible) */} +
+ + +

+ Manual webhook creation (alternative: if the button below doesn't work) +

+
+
+

+ Use the following curl command to create the GitHub webhook manually. Requires a token + with admin:repo_hook scope. +

+
+
+ +
+
+							{githubCurlCommand}
+						
+
+
+
+ + {/* Create webhook button */} +
+ + {createGithubWebhookMutation.isError && ( +

{createGithubWebhookMutation.error.message}

+ )} + {createGithubWebhookMutation.isSuccess && ( +

+ GitHub webhook created successfully. +

+ )} +
+
+ ); +} + +// ============================================================================ +// SCM Tab (GitHub) +// ============================================================================ + +interface SCMTabProject { + repo?: string | null; + baseBranch?: string | null; + branchPrefix?: string | null; +} + +export function SCMTab({ projectId, project }: { projectId: string; project?: SCMTabProject }) { + const queryClient = useQueryClient(); + + // Project-level SCM fields + const [repo, setRepo] = useState(project?.repo ?? ''); + const [baseBranch, setBaseBranch] = useState(project?.baseBranch ?? 'main'); + const [branchPrefix, setBranchPrefix] = useState(project?.branchPrefix ?? 'feature/'); + + useEffect(() => { + setRepo(project?.repo ?? ''); + setBaseBranch(project?.baseBranch ?? 'main'); + setBranchPrefix(project?.branchPrefix ?? 'feature/'); + }, [project?.repo, project?.baseBranch, project?.branchPrefix]); + + const saveMutation = useMutation({ + mutationFn: async () => { + // Save project-level SCM fields + await trpcClient.projects.update.mutate({ + id: projectId, + repo: repo || undefined, + baseBranch, + branchPrefix, + }); + + // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab + const result = await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'scm', + provider: 'github', + config: {}, + }); + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.getById.queryOptions({ id: projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.listFull.queryOptions().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return ( +
+ {/* Repository Settings */} +
+ +
+ + setRepo(e.target.value)} + placeholder="owner/repo" + /> +
+
+
+ + setBaseBranch(e.target.value)} + placeholder="main" + /> +
+
+ + setBranchPrefix(e.target.value)} + placeholder="feature/" + /> +
+
+
+ +
+ +

+ CASCADE uses two separate GitHub bot accounts to prevent feedback loops. The{' '} + implementer writes code and creates PRs. The reviewer{' '} + reviews PRs and can approve or request changes. +

+ + + +

+ Trigger configuration has moved to the Agents tab. +

+ +
+ + {saveMutation.isSuccess && Saved} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
+ +
+ + +
+ ); +} diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index be9d3ede..34d6787c 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -1,882 +1,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, ArrowLeft, ChevronRight, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; -import { engineCredentialKeys } from '@/components/projects/engine-secrets.js'; -import { EngineSettingsFields } from '@/components/settings/engine-settings-fields.js'; -import { ModelField } from '@/components/settings/model-field.js'; -import { - DefinitionTriggerToggles, - type ResolvedTrigger, -} from '@/components/shared/definition-trigger-toggles.js'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog.js'; -import { Badge } from '@/components/ui/badge.js'; -import { Input } from '@/components/ui/input.js'; -import { Label } from '@/components/ui/label.js'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select.js'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table.js'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip.js'; -import { - AGENT_LABELS, - CATEGORY_LABELS, - type TriggerParameterValue, -} from '@/lib/trigger-agent-mapping.js'; +import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; +import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { AgentPromptOverrides } from './agent-prompt-overrides.js'; - -interface AgentConfig { - id: number; - agentType: string; - model: string | null; - maxIterations: number | null; - agentEngine: string | null; - agentEngineSettings: Record> | null; - maxConcurrency: number | null; - systemPrompt: string | null; - taskPrompt: string | null; -} - -interface EngineSettingFieldOption { - value: string; - label: string; -} - -type EngineSettingField = - | { - key: string; - label: string; - type: 'select'; - description?: string; - options: EngineSettingFieldOption[]; - } - | { key: string; label: string; type: 'boolean'; description?: string } - | { - key: string; - label: string; - type: 'number'; - description?: string; - min?: number; - max?: number; - step?: number; - }; - -interface Engine { - id: string; - label: string; - settings?: { - title?: string; - description?: string; - fields: EngineSettingField[]; - }; -} - -// ============================================================================ -// Definition-Based Agent Section (New) -// ============================================================================ - -interface SaveConfigValues { - model: string; - maxIterations: string; - agentEngine: string; - maxConcurrency: string; - engineSettings: Record> | undefined; - systemPrompt: string; - taskPrompt: string; - /** True when the user explicitly cleared the system prompt override (send null, not the fallback text). */ - systemPromptCleared: boolean; - /** True when the user explicitly cleared the task prompt override (send null, not the fallback text). */ - taskPromptCleared: boolean; -} - -interface SystemDefaults { - model: string; - maxIterations: number; - agentEngine: string; - engineSettings: Record>; -} - -interface DefinitionAgentSectionProps { - agentType: string; - projectId: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { - pm: string | null; - scm: string | null; - }; - 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: ( - agentType: string, - event: string, - parameters: Record, - currentEnabled: boolean, - ) => void; - /** Project-level model (null = use system default). */ - projectModel: string | null; - /** Project-level engine (null = use system default). */ - projectEngine: string | null; - /** Project-level maxIterations (null = use system default). */ - projectMaxIterations: number | null; - /** System-level defaults from the backend. */ - systemDefaults: SystemDefaults | undefined; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tabbed detail panel managing Engine/Prompts/Triggers tabs with per-tab state, mutations, and trigger category grouping -function DefinitionAgentSection({ - agentType, - projectId, - config, - triggers, - integrations, - engines, - isSaving, - onSaveConfig, - saveSuccessNonce, - onDeleteConfig, - onTriggerToggle, - onTriggerParamChange, - projectModel, - projectEngine, - projectMaxIterations, - systemDefaults, -}: DefinitionAgentSectionProps) { - 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 — engine fields - 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() ?? ''); - const [engineSettings, setEngineSettings] = useState< - Record> | undefined - >(config?.agentEngineSettings ?? undefined); - - // Local form state — prompt fields (initialized by AgentPromptOverrides component) - const [systemPrompt, setSystemPrompt] = useState(config?.systemPrompt ?? ''); - const [taskPrompt, setTaskPrompt] = useState(config?.taskPrompt ?? ''); - // Track whether the user explicitly cleared a prompt override so we can send null on save - // instead of the fallback display text (which would create a duplicate "custom" override). - const [systemPromptCleared, setSystemPromptCleared] = useState(false); - const [taskPromptCleared, setTaskPromptCleared] = useState(false); - - const effectiveEngineId = agentEngine || ''; - const effectiveEngine = engines.find((engine) => engine.id === effectiveEngineId); - - // Resolved inherited engine — project override or system default - const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? 'claude-code'; - // Per-field engine defaults for the EngineSettingsFields component - const engineDefaults = - systemDefaults && effectiveEngineId - ? systemDefaults.engineSettings[effectiveEngineId] - : undefined; - - // Resolved inherited model and iterations (walk the chain: project → system) - const inheritedModel = projectModel ?? systemDefaults?.model; - const inheritedMaxIterations = projectMaxIterations ?? systemDefaults?.maxIterations; - - // 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() ?? ''); - setEngineSettings(config?.agentEngineSettings ?? undefined); - setSystemPrompt(config?.systemPrompt ?? ''); - setTaskPrompt(config?.taskPrompt ?? ''); - setSystemPromptCleared(false); - setTaskPromptCleared(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); - } - 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 = { - pm: [], - scm: [], - internal: [], - }; - - for (const trigger of triggers) { - // Extract category from event (e.g., "pm:card-moved" -> "pm") - const [category] = trigger.event.split(':'); - if (category in groups) { - // Filter by provider if the trigger has provider restrictions - if (trigger.providers && trigger.providers.length > 0) { - const activeProvider = integrations[category as keyof typeof integrations]; - const matchesProvider = trigger.providers.some((p) => p === activeProvider); - if (!matchesProvider) continue; - } - groups[category].push(trigger); - } - } - - return groups; - }, [triggers, integrations]); - - const hasTriggers = - triggersByCategory.pm.length > 0 || - triggersByCategory.scm.length > 0 || - triggersByCategory.internal.length > 0; - - const handleSave = () => { - onSaveConfig(agentType, config?.id ?? null, { - model, - maxIterations, - agentEngine, - maxConcurrency, - engineSettings, - systemPrompt, - taskPrompt, - systemPromptCleared, - taskPromptCleared, - }); - }; - - const handleCancel = () => { - setModel(config?.model ?? ''); - setMaxIterations(config?.maxIterations?.toString() ?? ''); - setAgentEngine(config?.agentEngine ?? ''); - setMaxConcurrency(config?.maxConcurrency?.toString() ?? ''); - setEngineSettings(config?.agentEngineSettings ?? undefined); - setSystemPrompt(config?.systemPrompt ?? ''); - setTaskPrompt(config?.taskPrompt ?? ''); - setSystemPromptCleared(false); - setTaskPromptCleared(false); - }; - - const handleDelete = () => { - if (config && window.confirm('Delete this agent config?')) { - onDeleteConfig(config.id); - } - }; - - return ( -
- - - Engine - Prompts - Triggers - - - {/* Engine Tab */} - -
- - -
-
- - -
- {effectiveEngine && ( - - )} -
-
- - setMaxIterations(e.target.value)} - placeholder={ - inheritedMaxIterations !== undefined - ? `${inheritedMaxIterations} (inherited)` - : 'Optional' - } - /> -
-
- - setMaxConcurrency(e.target.value)} - placeholder="Optional" - /> -
-
-
- - {/* Prompts Tab */} - - { - setSystemPrompt(v); - // User is editing manually — cancel any pending clear - setSystemPromptCleared(false); - }} - taskPrompt={taskPrompt} - onTaskPromptChange={(v) => { - setTaskPrompt(v); - // User is editing manually — cancel any pending clear - setTaskPromptCleared(false); - }} - onSystemPromptClear={() => setSystemPromptCleared(true)} - onTaskPromptClear={() => setTaskPromptCleared(true)} - /> - - - {/* Triggers Tab */} - - {(['pm', 'scm', 'internal'] as const).map((category) => { - const categoryTriggers = triggersByCategory[category]; - if (categoryTriggers.length === 0) return null; - - return ( -
-

- {CATEGORY_LABELS[category] ?? category} Triggers -

- onTriggerToggle(agentType, event, enabled)} - onParamChange={(event, params) => { - // Find the current trigger to get its enabled state - const currentTrigger = categoryTriggers.find((t) => t.event === event); - onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); - }} - idPrefix={`${agentType}-${category}`} - /> -
- ); - })} - - {!hasTriggers && ( -

- No trigger configuration for this agent. -

- )} -
-
- - {/* Footer actions — outside tabs, applies globally */} -
-
- - - {saved && Saved} -
- {config && ( - - )} -
-
- ); -} - -/** - * Returns true when the given engine has at least one credential key configured. - * Derived from ENGINE_SECRETS in engine-secrets.ts — no separate mapping to maintain. - * If the engine is not in the map, we conservatively assume credentials are present. - */ -function engineHasCredentials(engineId: string, configuredCredentialKeys: Set): boolean { - const requiredKeys = engineCredentialKeys[engineId]; - if (!requiredKeys) return true; // Unknown engine — assume ok - return requiredKeys.some((key) => configuredCredentialKeys.has(key)); -} - -// ============================================================================ -// Agent List View -// ============================================================================ - -function countActiveTriggers( - triggers: ResolvedTrigger[], - integrations: { pm: string | null; scm: string | null }, -): number { - return triggers.filter((t) => { - if (!t.enabled) return false; - const [category] = t.event.split(':'); - if (t.providers && t.providers.length > 0) { - const activeProvider = integrations[category as keyof typeof integrations]; - return t.providers.some((p) => p === activeProvider); - } - return true; - }).length; -} - -interface AgentRowProps { - type: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { pm: string | null; scm: string | null }; - onSelect: (agentType: string) => void; - onDeleteRequest: (id: number, label: string) => void; - /** Project-level model to show as "inherited" when agent has no override. */ - projectModel: string | null; - /** Project-level engine to show as "inherited" when agent has no override. */ - projectEngine: string | null; - /** System-level defaults. */ - systemDefaults: SystemDefaults | undefined; - /** Set of credential env-var keys that are configured for this project. */ - configuredCredentialKeys: Set; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: table row with multiple computed display values (model, engine, trigger count) and layered inheritance fallbacks -function AgentRow({ - type, - config, - triggers, - integrations, - onSelect, - onDeleteRequest, - projectModel, - projectEngine, - systemDefaults, - configuredCredentialKeys, -}: AgentRowProps) { - const label = (AGENT_LABELS as Record)[type] ?? type; - const activeTriggerCount = countActiveTriggers(triggers, integrations); - const modelInfo = config?.model ?? null; - const engineInfo = config?.agentEngine ?? null; - const hasCustomEngineSettings = - config?.agentEngineSettings != null && Object.keys(config.agentEngineSettings).length > 0; - - // Fallback display: show inherited model/engine when agent has no specific override - const inheritedModel = projectModel ?? systemDefaults?.model ?? null; - const inheritedEngine = projectEngine ?? systemDefaults?.agentEngine ?? null; - const displayModel = modelInfo ?? (inheritedModel ? `${inheritedModel} (inherited)` : null); - const displayEngine = engineInfo ?? (inheritedEngine ? `${inheritedEngine} (inherited)` : null); - - // Check if the agent's effective engine has credentials configured - // Only check when there is an explicit agent-level engine override - const agentEngineId = config?.agentEngine ?? null; - const hasMissingCredentials = - agentEngineId !== null && !engineHasCredentials(agentEngineId, configuredCredentialKeys); - - return ( - onSelect(type)}> - {label} - - {activeTriggerCount === 0 ? ( - - Inactive - - ) : config ? ( -
- - Configured - - {hasMissingCredentials && ( - - - - - Missing credentials - - - - This agent uses the {agentEngineId} engine but no credentials are configured for - it. Configure credentials on the Harness tab. - - - )} -
- ) : ( - - Default - - )} -
- - {displayModel || displayEngine ? ( - - {displayEngine && {displayEngine}} - {displayEngine && displayModel && · } - {displayModel && {displayModel}} - {hasCustomEngineSettings && ( - - Custom settings - - )} - - ) : ( - - )} - - - {activeTriggerCount > 0 ? ( - {activeTriggerCount} active - ) : ( - - - - - None - - - - No triggers configured — this agent won't process any events - - - )} - - -
- {config && ( - - )} - -
-
-
- ); -} - -interface AgentListViewProps { - enabledAgentTypes: string[]; - availableAgentTypes: string[]; - configByAgent: Map; - triggersByAgent: Map; - integrations: { pm: string | null; scm: string | null }; - onSelect: (agentType: string) => void; - onDelete: (id: number) => void; - onEnable: (agentType: string) => void; - isDeleting: boolean; - isEnabling: boolean; - projectModel: string | null; - projectEngine: string | null; - systemDefaults: SystemDefaults | undefined; - /** Set of credential env-var keys that are configured for this project. */ - configuredCredentialKeys: Set; -} - -function AgentListView({ - enabledAgentTypes, - availableAgentTypes, - configByAgent, - triggersByAgent, - integrations, - onSelect, - onDelete, - onEnable, - isDeleting, - isEnabling, - projectModel, - projectEngine, - systemDefaults, - configuredCredentialKeys, -}: AgentListViewProps) { - const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null); - - return ( - <> - {enabledAgentTypes.length === 0 ? ( -
- No agents enabled. Enable agents below to start processing. -
- ) : ( -
- - - - - Agent - Status - Engine / Model - Active Triggers - - - - - {enabledAgentTypes.map((type) => ( - setDeleteTarget({ id, label })} - projectModel={projectModel} - projectEngine={projectEngine} - systemDefaults={systemDefaults} - configuredCredentialKeys={configuredCredentialKeys} - /> - ))} - -
-
-
- )} - - {availableAgentTypes.length > 0 && ( -
-

Available Agents

-
- {availableAgentTypes.map((agentType) => { - const label = - (AGENT_LABELS as Record)[agentType] ?? agentType; - return ( -
- {label} - -
- ); - })} -
-
- )} - - !open && setDeleteTarget(null)}> - - - Delete Agent Config - - Are you sure you want to delete the config for {deleteTarget?.label}? - The agent will be disabled and no longer process any events. This action cannot be - undone. - - - - Cancel - { - if (deleteTarget) { - onDelete(deleteTarget.id); - setDeleteTarget(null); - } - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {isDeleting ? 'Deleting...' : 'Delete'} - - - - - - ); -} - -// ============================================================================ -// Agent Detail View -// ============================================================================ - -interface AgentDetailViewProps { - agentType: string; - projectId: string; - config: AgentConfig | null; - triggers: ResolvedTrigger[]; - integrations: { pm: string | null; scm: string | null }; - 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: ( - agentType: string, - event: string, - parameters: Record, - currentEnabled: boolean, - ) => void; - onBack: () => void; - projectModel: string | null; - projectEngine: string | null; - projectMaxIterations: number | null; - systemDefaults: SystemDefaults | undefined; -} - -function AgentDetailView({ - agentType, - projectId, - config, - triggers, - integrations, - engines, - isSaving, - onSaveConfig, - saveSuccessNonce, - onDeleteConfig, - onTriggerToggle, - onTriggerParamChange, - onBack, - projectModel, - projectEngine, - projectMaxIterations, - systemDefaults, -}: AgentDetailViewProps) { - const label = (AGENT_LABELS as Record)[agentType] ?? agentType; - - return ( -
-
- -
-
-

{label}

-

- Configure model, engine, and trigger settings for the {label} agent. -

-
- { - onDeleteConfig(id); - onBack(); - }} - onTriggerToggle={onTriggerToggle} - onTriggerParamChange={onTriggerParamChange} - projectModel={projectModel} - projectEngine={projectEngine} - projectMaxIterations={projectMaxIterations} - systemDefaults={systemDefaults} - /> -
- ); -} +import { AgentDetailView } from './agent-config-detail.js'; +import { AgentListView } from './agent-config-list.js'; +import type { + AgentConfig, + Engine, + SaveConfigValues, + SystemDefaults, +} from './agent-config-types.js'; // ============================================================================ // Main Component @@ -1055,7 +190,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { // Project-level and system-level defaults for inheritance display const projectData = projectQuery.data; - const systemDefaults = defaultsQuery.data + const systemDefaults: SystemDefaults | undefined = defaultsQuery.data ? { model: defaultsQuery.data.model, maxIterations: defaultsQuery.data.maxIterations,