diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx new file mode 100644 index 0000000000..7c490c3002 --- /dev/null +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { render, screen, waitFor } from '@/test/utils/render'; + +import { CustomAgentActiveToggle } from './custom-agent-active-toggle'; + +const mockActivateVersion = vi.fn(); +const mockUnpublish = vi.fn(); + +vi.mock('../hooks/use-custom-agent-mutations', () => ({ + useActivateCustomAgentVersion: () => mockActivateVersion, + useUnpublishCustomAgent: () => mockUnpublish, +})); + +vi.mock('@/app/hooks/use-toast', () => ({ + toast: vi.fn(), + useToast: () => ({ toast: vi.fn() }), +})); + +function createAgent( + overrides: Partial<{ + _id: string; + displayName: string; + rootVersionId: string; + status: 'draft' | 'active' | 'archived'; + versionNumber: number; + }> = {}, +) { + return { + _id: 'agent-1', + displayName: 'Test Agent', + rootVersionId: 'agent-root-1', + status: 'active' as const, + versionNumber: 1, + ...overrides, + }; +} + +describe('CustomAgentActiveToggle', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockActivateVersion.mockResolvedValue(null); + mockUnpublish.mockResolvedValue(null); + }); + + describe('rendering', () => { + it('renders checked switch when agent is active', () => { + render(); + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('data-state', 'checked'); + }); + + it('renders unchecked switch when agent is archived', () => { + render( + , + ); + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('data-state', 'unchecked'); + }); + + it('renders disabled switch when agent is draft', () => { + render( + , + ); + const toggle = screen.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + + it('renders with label when provided', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + describe('interactions', () => { + it('calls activateVersion when toggling on an archived agent', async () => { + const { user } = render( + , + ); + + await user.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(mockActivateVersion).toHaveBeenCalledWith({ + customAgentId: 'agent-root-1', + targetVersion: 2, + }); + }); + }); + + it('shows confirmation dialog when toggling off an active agent', async () => { + const { user } = render( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(screen.getByText('Deactivate agent')).toBeInTheDocument(); + expect(mockUnpublish).not.toHaveBeenCalled(); + }); + + it('calls unpublish when confirming deactivation', async () => { + const { user } = render( + , + ); + + await user.click(screen.getByRole('switch')); + + const confirmButton = screen.getByRole('button', { + name: /deactivate/i, + }); + await user.click(confirmButton); + + await waitFor(() => { + expect(mockUnpublish).toHaveBeenCalledWith({ + customAgentId: 'agent-root-1', + }); + }); + }); + + it('does not toggle when draft agent is clicked', async () => { + const { user } = render( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(mockActivateVersion).not.toHaveBeenCalled(); + expect(mockUnpublish).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx new file mode 100644 index 0000000000..6738fda54b --- /dev/null +++ b/services/platform/app/features/custom-agents/components/custom-agent-active-toggle.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useState } from 'react'; + +import { ConfirmDialog } from '@/app/components/ui/dialog/confirm-dialog'; +import { Switch } from '@/app/components/ui/forms/switch'; +import { toast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; +import { toId } from '@/lib/utils/type-guards'; + +import type { CustomAgentRow } from './custom-agent-table'; + +import { + useActivateCustomAgentVersion, + useUnpublishCustomAgent, +} from '../hooks/use-custom-agent-mutations'; + +interface CustomAgentActiveToggleProps { + agent: Pick< + CustomAgentRow, + '_id' | 'displayName' | 'rootVersionId' | 'status' | 'versionNumber' + >; + label?: string; +} + +export function CustomAgentActiveToggle({ + agent, + label, +}: CustomAgentActiveToggleProps) { + const { t } = useT('settings'); + const { t: tCommon } = useT('common'); + + const [showDeactivateDialog, setShowDeactivateDialog] = useState(false); + const [isToggling, setIsToggling] = useState(false); + + const activateVersion = useActivateCustomAgentVersion(); + const unpublishAgent = useUnpublishCustomAgent(); + + const rootId = agent.rootVersionId ?? agent._id; + const isActive = agent.status === 'active'; + const isDraft = agent.status === 'draft'; + + const handleActivate = useCallback(async () => { + setIsToggling(true); + try { + await activateVersion({ + customAgentId: toId<'customAgents'>(rootId), + targetVersion: agent.versionNumber, + }); + toast({ + title: t('customAgents.agentPublished'), + variant: 'success', + }); + } catch (error) { + console.error('Failed to activate agent:', error); + toast({ + title: t('customAgents.agentPublishFailed'), + variant: 'destructive', + }); + } finally { + setIsToggling(false); + } + }, [activateVersion, rootId, agent.versionNumber, t]); + + const handleDeactivateConfirm = useCallback(async () => { + setIsToggling(true); + try { + await unpublishAgent({ + customAgentId: toId<'customAgents'>(rootId), + }); + setShowDeactivateDialog(false); + toast({ + title: t('customAgents.agentDeactivated'), + variant: 'success', + }); + } catch (error) { + console.error('Failed to deactivate agent:', error); + toast({ + title: t('customAgents.agentDeactivateFailed'), + variant: 'destructive', + }); + } finally { + setIsToggling(false); + } + }, [unpublishAgent, rootId, t]); + + const handleToggle = useCallback( + (checked: boolean) => { + if (checked) { + void handleActivate(); + } else { + setShowDeactivateDialog(true); + } + }, + [handleActivate], + ); + + return ( + <> + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + disabled={isDraft || isToggling} + label={label} + aria-label={t('customAgents.activeToggle.ariaLabel')} + /> + + + + ); +} diff --git a/services/platform/app/features/custom-agents/components/custom-agent-table.tsx b/services/platform/app/features/custom-agents/components/custom-agent-table.tsx index 8ef5d15079..0e634961d3 100644 --- a/services/platform/app/features/custom-agents/components/custom-agent-table.tsx +++ b/services/platform/app/features/custom-agents/components/custom-agent-table.tsx @@ -16,6 +16,7 @@ import { api } from '@/convex/_generated/api'; import { useT } from '@/lib/i18n/client'; import { isKeyOf } from '@/lib/utils/type-guards'; +import { CustomAgentActiveToggle } from './custom-agent-active-toggle'; import { CustomAgentRowActions } from './custom-agent-row-actions'; import { CustomAgentsActionMenu } from './custom-agents-action-menu'; @@ -109,6 +110,12 @@ export function CustomAgentTable({ }, size: 140, }, + { + id: 'active', + header: t('customAgents.columns.active'), + size: 80, + cell: ({ row }) => , + }, { id: 'modelPreset', header: t('customAgents.columns.modelPreset'), diff --git a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx index 3e881832c1..b4083168c3 100644 --- a/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx +++ b/services/platform/app/routes/dashboard/$id/custom-agents/$agentId/index.tsx @@ -8,6 +8,7 @@ import { Select } from '@/app/components/ui/forms/select'; import { Textarea } from '@/app/components/ui/forms/textarea'; import { Stack, NarrowContainer } from '@/app/components/ui/layout/layout'; import { AutoSaveIndicator } from '@/app/features/custom-agents/components/auto-save-indicator'; +import { CustomAgentActiveToggle } from '@/app/features/custom-agents/components/custom-agent-active-toggle'; import { useAutoSave } from '@/app/features/custom-agents/hooks/use-auto-save'; import { useUpdateCustomAgentMetadata } from '@/app/features/custom-agents/hooks/use-custom-agent-mutations'; import { useCustomAgentVersion } from '@/app/features/custom-agents/hooks/use-custom-agent-version-context'; @@ -138,6 +139,18 @@ function GeneralTab() { + {agent && ( + + +

+ {t('customAgents.general.activeHelp')} +

+
+ )} +