Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<CustomAgentActiveToggle agent={createAgent()} />);
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('data-state', 'checked');
});

it('renders unchecked switch when agent is archived', () => {
render(
<CustomAgentActiveToggle agent={createAgent({ status: 'archived' })} />,
);
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('data-state', 'unchecked');
});

it('renders disabled switch when agent is draft', () => {
render(
<CustomAgentActiveToggle agent={createAgent({ status: 'draft' })} />,
);
const toggle = screen.getByRole('switch');
expect(toggle).toBeDisabled();
});

it('renders with label when provided', () => {
render(<CustomAgentActiveToggle agent={createAgent()} label="Active" />);
expect(screen.getByText('Active')).toBeInTheDocument();
});
});

describe('interactions', () => {
it('calls activateVersion when toggling on an archived agent', async () => {
const { user } = render(
<CustomAgentActiveToggle
agent={createAgent({ status: 'archived', versionNumber: 2 })}
/>,
);

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(
<CustomAgentActiveToggle
agent={createAgent({ displayName: 'My Agent' })}
/>,
);

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(
<CustomAgentActiveToggle
agent={createAgent({ displayName: 'My Agent' })}
/>,
);

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(
<CustomAgentActiveToggle agent={createAgent({ status: 'draft' })} />,
);

await user.click(screen.getByRole('switch'));

expect(mockActivateVersion).not.toHaveBeenCalled();
expect(mockUnpublish).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Switch
checked={isActive}
onCheckedChange={handleToggle}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={isDraft || isToggling}
label={label}
aria-label={t('customAgents.activeToggle.ariaLabel')}
/>

<ConfirmDialog
open={showDeactivateDialog}
onOpenChange={setShowDeactivateDialog}
title={t('customAgents.deactivateDialog.title')}
description={t('customAgents.deactivateDialog.description', {
name: agent.displayName,
})}
confirmText={tCommon('actions.deactivate')}
loadingText={tCommon('actions.deactivating')}
isLoading={isToggling}
onConfirm={handleDeactivateConfirm}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -109,6 +110,12 @@ export function CustomAgentTable({
},
size: 140,
},
{
id: 'active',
header: t('customAgents.columns.active'),
size: 80,
cell: ({ row }) => <CustomAgentActiveToggle agent={row.original} />,
},
{
id: 'modelPreset',
header: t('customAgents.columns.modelPreset'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,6 +139,18 @@ function GeneralTab() {
<AutoSaveIndicator status={status} />
</div>

{agent && (
<Stack gap={2}>
<CustomAgentActiveToggle
agent={agent}
label={t('customAgents.general.active')}
/>
<p className="text-muted-foreground text-xs">
{t('customAgents.general.activeHelp')}
</p>
</Stack>
)}

<Stack gap={3}>
<Input
id="name"
Expand Down
8 changes: 8 additions & 0 deletions services/platform/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@
"noAgentsDescription": "Create your first custom agent to specialize AI behavior for your team.",
"columns": {
"displayName": "Name",
"active": "Active",
"modelPreset": "Model",
"tools": "Tools",
"version": "Version",
Expand All @@ -636,6 +637,9 @@
"agentPublishFailed": "Failed to publish agent",
"agentDeactivated": "Agent deactivated",
"agentDeactivateFailed": "Failed to deactivate agent",
"activeToggle": {
"ariaLabel": "Toggle agent active state"
},
"deactivateDialog": {
"title": "Deactivate agent",
"description": "Are you sure you want to deactivate \"{name}\"? The agent will no longer be available until you activate it again."
Expand Down Expand Up @@ -666,6 +670,10 @@
"continue": "Continue",
"creating": "Creating..."
},
"general": {
"active": "Active",
"activeHelp": "When active, this agent is available for use. Deactivating it will make it unavailable until you activate it again."
},
"form": {
"sectionGeneral": "General",
"sectionGeneralDescription": "Basic information about the agent",
Expand Down
Loading