From 40fba2564aff847b856f8e9cc1ac4c6b611e56f7 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 10:42:31 +0000 Subject: [PATCH] feat(dashboard): move email-joke sender filter to Agent Configs tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EmailJokeConfig sender filter widget was placed in the Email integration tab, but it is agent-specific trigger configuration and belongs in the Agent Configs tab alongside PM and SCM triggers. Changes: - Add 'email-joke' to ALL_AGENT_TYPES so it appears as a section in Agent Configs - Move AGENT_LABELS to trigger-agent-mapping.ts and type it as Record — adding a new agent type without a label is now a compile error rather than a silent fallback - Export EMAIL_TRIGGER_AGENTS (Set) from trigger-agent-mapping.ts — replaces the magic string 'email-joke' that was hard-coded in the generic AgentSection component - Add 'email-joke': [] to AGENT_TRIGGER_MAP for consistency with all other known agent types - Render EmailJokeConfig inside the email-joke AgentSection under an "Email Triggers" heading when expanded - Remove EmailJokeConfig from the integration-form email tab - Remove "Add Custom Agent Config" button — only supported agent types defined in ALL_AGENT_TYPES are valid; per-section "Add Config" buttons pre-seed the type so it is always read-only in the dialog - Suppress pre-existing noExcessiveCognitiveComplexity biome warning in EmailWizard's multi-provider initialization useEffect - Add tests for AGENT_LABELS, EMAIL_TRIGGER_AGENTS, ALL_AGENT_TYPES completeness, and email-joke trigger definitions Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/web/triggerAgentMapping.test.ts | 81 ++++++++++++++++++- web/src/components/projects/email-wizard.tsx | 1 + .../components/projects/integration-form.tsx | 15 ++-- .../projects/project-agent-configs.tsx | 58 ++++++------- web/src/lib/trigger-agent-mapping.ts | 18 +++++ 5 files changed, 134 insertions(+), 39 deletions(-) diff --git a/tests/unit/web/triggerAgentMapping.test.ts b/tests/unit/web/triggerAgentMapping.test.ts index a9a392e3..f964604d 100644 --- a/tests/unit/web/triggerAgentMapping.test.ts +++ b/tests/unit/web/triggerAgentMapping.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getTriggersForAgent } from '../../../web/src/lib/trigger-agent-mapping.js'; +import { + AGENT_LABELS, + ALL_AGENT_TYPES, + EMAIL_TRIGGER_AGENTS, + getTriggersForAgent, +} from '../../../web/src/lib/trigger-agent-mapping.js'; describe('getTriggersForAgent', () => { it('returns all triggers when no opts given (backward compatibility)', () => { @@ -89,3 +94,77 @@ describe('getTriggersForAgent — review trigger dot-notation keys and defaults' expect(defaults.prOpened).toBe(false); }); }); + +describe('ALL_AGENT_TYPES', () => { + it('includes email-joke', () => { + expect(ALL_AGENT_TYPES).toContain('email-joke'); + }); + + it('contains all expected agent types in order', () => { + expect(ALL_AGENT_TYPES).toEqual([ + 'splitting', + 'planning', + 'implementation', + 'review', + 'respond-to-review', + 'respond-to-ci', + 'respond-to-pr-comment', + 'respond-to-planning-comment', + 'email-joke', + ]); + }); +}); + +describe('AGENT_LABELS', () => { + it('has a label for every entry in ALL_AGENT_TYPES', () => { + for (const type of ALL_AGENT_TYPES) { + expect(AGENT_LABELS).toHaveProperty(type); + expect(typeof AGENT_LABELS[type]).toBe('string'); + expect(AGENT_LABELS[type].length).toBeGreaterThan(0); + } + }); + + it('maps email-joke to a friendly label', () => { + expect(AGENT_LABELS['email-joke']).toBe('Email Joke'); + }); + + it('has no entries beyond ALL_AGENT_TYPES', () => { + const knownTypes = new Set(ALL_AGENT_TYPES); + for (const key of Object.keys(AGENT_LABELS)) { + expect(knownTypes).toContain(key); + } + }); +}); + +describe('EMAIL_TRIGGER_AGENTS', () => { + it('contains email-joke', () => { + expect(EMAIL_TRIGGER_AGENTS.has('email-joke')).toBe(true); + }); + + it('does not contain non-email agents', () => { + expect(EMAIL_TRIGGER_AGENTS.has('implementation')).toBe(false); + expect(EMAIL_TRIGGER_AGENTS.has('review')).toBe(false); + expect(EMAIL_TRIGGER_AGENTS.has('splitting')).toBe(false); + }); + + it('every entry is a known agent type', () => { + const knownTypes = new Set(ALL_AGENT_TYPES); + for (const agentType of EMAIL_TRIGGER_AGENTS) { + expect(knownTypes).toContain(agentType); + } + }); +}); + +describe('getTriggersForAgent — email-joke', () => { + it('returns empty array for email-joke (triggers are handled by a custom widget, not toggles)', () => { + expect(getTriggersForAgent('email-joke')).toHaveLength(0); + }); + + it('returns empty array for email-joke with category: pm', () => { + expect(getTriggersForAgent('email-joke', { category: 'pm' })).toHaveLength(0); + }); + + it('returns empty array for email-joke with category: scm', () => { + expect(getTriggersForAgent('email-joke', { category: 'scm' })).toHaveLength(0); + }); +}); diff --git a/web/src/components/projects/email-wizard.tsx b/web/src/components/projects/email-wizard.tsx index 5cdc7997..c25b1490 100644 --- a/web/src/components/projects/email-wizard.tsx +++ b/web/src/components/projects/email-wizard.tsx @@ -532,6 +532,7 @@ export function EmailWizard({ // Initialize from existing integration. // For Gmail: wait until orgCredentials has loaded so we can resolve the email // address from the credential name and pre-confirm the verify step. + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider initialization requires coordinating several async conditions and credential lookups in a single effect useEffect(() => { if (initDoneRef.current || !initialProvider || !initialCredentials) return; if (initialProvider === 'gmail' && orgCredentials.length === 0) return; diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index dcb40904..9ece60c9 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -3,7 +3,7 @@ import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { EmailJokeConfig, EmailWizard } from './email-wizard.js'; +import { EmailWizard } from './email-wizard.js'; import { PMWizard } from './pm-wizard.js'; type IntegrationCategory = 'pm' | 'scm' | 'email'; @@ -425,14 +425,11 @@ export function IntegrationForm({ projectId }: { projectId: string }) { )} {activeTab === 'email' && ( -
- - -
+ )} ); diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 5f731b64..5890d5a3 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -11,7 +11,10 @@ import { SelectValue, } from '@/components/ui/select.js'; import { + AGENT_LABELS, ALL_AGENT_TYPES, + EMAIL_TRIGGER_AGENTS, + type KnownAgentType, LIFECYCLE_TRIGGERS, SHARED_PM_TRIGGERS, getTriggersForAgent, @@ -19,8 +22,9 @@ 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, Plus, Trash2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; +import { EmailJokeConfig } from './email-wizard.js'; interface AgentConfig { id: number; @@ -31,18 +35,6 @@ interface AgentConfig { prompt: string | null; } -/** Friendly labels for known agent types */ -const AGENT_LABELS: Record = { - splitting: 'Splitting', - planning: 'Planning', - implementation: 'Implementation', - review: 'Review', - 'respond-to-review': 'Respond to Review', - 'respond-to-ci': 'Respond to CI', - 'respond-to-pr-comment': 'Respond to PR Comment', - 'respond-to-planning-comment': 'Respond to Planning Comment', -}; - function AgentConfigBadge({ config }: { config: AgentConfig | null }) { if (!config) { return Using defaults; @@ -86,6 +78,7 @@ function extractRelevantTriggers( function AgentSection({ agentType, + projectId, config, pmTriggers, scmTriggers, @@ -95,6 +88,8 @@ function AgentSection({ onSaveTriggers, }: { agentType: string; + /** Only required for agents in EMAIL_TRIGGER_AGENTS */ + projectId?: string; config: AgentConfig | null; pmTriggers: Record; scmTriggers: Record; @@ -117,8 +112,9 @@ function AgentSection({ const agentPmTriggers = getTriggersForAgent(agentType, { pmProvider, category: 'pm' }); const agentScmTriggers = getTriggersForAgent(agentType, { category: 'scm' }); + const hasEmailTriggers = EMAIL_TRIGGER_AGENTS.has(agentType as KnownAgentType); - const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0; + const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0 || hasEmailTriggers; // Sync local state when props change (e.g., after another agent section saves shared triggers) useEffect(() => { @@ -167,7 +163,9 @@ function AgentSection({ ) : ( )} - {AGENT_LABELS[agentType] ?? agentType} + + {(AGENT_LABELS as Record)[agentType] ?? agentType} +
@@ -252,6 +250,16 @@ function AgentSection({
)} + {/* Email Triggers */} + {hasEmailTriggers && projectId && ( +
+

+ Email Triggers +

+ +
+ )} + {!hasTriggers && (

No trigger configuration for this agent. @@ -286,7 +294,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { const configsQueryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey; const integrationsQueryKey = trpc.projects.integrations.list.queryOptions({ projectId }).queryKey; - function openCreate(defaultAgentType = '') { + function openCreate(defaultAgentType: string) { setEditing(null); setAgentType(defaultAgentType); setModel(''); @@ -457,6 +465,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { - {/* Add config for custom agent type */} - - {/* Shared PM triggers section */} {filteredSharedPmTriggers.length > 0 && (

@@ -569,10 +570,9 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { setAgentType(e.target.value)} - placeholder="e.g. implementation, review" - required + value={(AGENT_LABELS as Record)[agentType] ?? agentType} + readOnly + className="bg-muted" />
diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index fa263f1d..573bf35f 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -209,6 +209,7 @@ export const AGENT_TRIGGER_MAP: Record = { }, ], 'respond-to-planning-comment': [], + 'email-joke': [], }; /** @@ -299,6 +300,23 @@ export const ALL_AGENT_TYPES = [ 'respond-to-ci', 'respond-to-pr-comment', 'respond-to-planning-comment', + 'email-joke', ] as const; export type KnownAgentType = (typeof ALL_AGENT_TYPES)[number]; + +/** Friendly display labels for all known agent types */ +export const AGENT_LABELS: Record = { + splitting: 'Splitting', + planning: 'Planning', + implementation: 'Implementation', + review: 'Review', + 'respond-to-review': 'Respond to Review', + 'respond-to-ci': 'Respond to CI', + 'respond-to-pr-comment': 'Respond to PR Comment', + 'respond-to-planning-comment': 'Respond to Planning Comment', + 'email-joke': 'Email Joke', +}; + +/** Agent types that use email-based trigger configuration (custom widget, not toggle-based) */ +export const EMAIL_TRIGGER_AGENTS = new Set(['email-joke']);