refactor(agent): move delete action to header

This commit is contained in:
RockChinQ
2026-08-25 23:34:35 +08:00
parent 2aceefce47
commit 114612a984
4 changed files with 213 additions and 215 deletions
+73 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Trash2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Agent } from '@/app/infra/entities/api';
@@ -11,6 +12,15 @@ import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
@@ -34,6 +44,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
@@ -111,6 +123,25 @@ export default function AgentDetailContent({ id }: { id: string }) {
}
}
async function deleteAgent() {
setDeleting(true);
try {
await httpClient.deleteAgent(id);
toast.success(t('agents.deleteSuccess'));
setDeleteConfirmOpen(false);
await refreshPipelines();
navigate('/home/agents');
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.deleteError') + message);
} finally {
setDeleting(false);
}
}
return (
<>
<ProcessorDetailWorkbench
@@ -127,6 +158,19 @@ export default function AgentDetailContent({ id }: { id: string }) {
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
headerActions={
canManage ? (
<Button
type="button"
variant="destructive"
disabled={formSaving || deleting}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
) : undefined
}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
@@ -141,10 +185,6 @@ export default function AgentDetailContent({ id }: { id: string }) {
}
refreshPipelines();
}}
onDeleted={() => {
refreshPipelines();
navigate('/home/agents');
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
@@ -181,6 +221,35 @@ export default function AgentDetailContent({ id }: { id: string }) {
defaultEmoji="🤖"
onSave={saveBasicInfo}
/>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription>
{t('agents.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={deleting}
onClick={() => setDeleteConfirmOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleting}
onClick={deleteAgent}
>
<Trash2 className="size-4" />
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
import { Bot, SlidersHorizontal, Zap } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import {
@@ -22,7 +22,6 @@ import {
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
@@ -32,13 +31,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
@@ -58,17 +50,12 @@ export interface AgentRunnerStatus {
interface AgentFormComponentProps {
agentId: string;
onFinish: (agent?: Partial<Agent>) => void;
onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
}
export type AgentConfigSection =
| 'events'
| 'runner'
| 'runner_config'
| 'basic';
export type AgentConfigSection = 'events' | 'runner' | 'runner_config';
export interface AgentFormHandle {
openSection: (section: AgentConfigSection) => void;
@@ -114,7 +101,6 @@ function AgentFormComponent(
{
agentId,
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
onRunnerStatusChange,
@@ -128,8 +114,6 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('events');
const isSavingRef = useRef(false);
@@ -283,11 +267,6 @@ function AgentFormComponent(
: t('pipelines.configuration'),
icon: SlidersHorizontal,
},
{
name: 'basic',
label: t('common.management'),
icon: Power,
},
];
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
@@ -472,7 +451,6 @@ function AgentFormComponent(
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
try {
await httpClient.updateAgent(agentId, agent);
@@ -489,7 +467,6 @@ function AgentFormComponent(
return false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
}
},
@@ -533,192 +510,110 @@ function AgentFormComponent(
[form, saveValues],
);
function confirmDelete() {
httpClient
.deleteAgent(agentId)
.then(() => {
toast.success(t('agents.deleteSuccess'));
setShowDeleteConfirm(false);
onDeleted();
})
.catch((err) => {
toast.error(t('agents.deleteError') + err.msg);
});
}
return (
<>
<div className="h-full p-0 flex flex-col">
<Form {...form}>
<form
id="agent-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<Tabs
value={activeSection}
onValueChange={(value) =>
setActiveSection(value as AgentConfigSection)
}
>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[42rem] w-full grid-cols-[repeat(3,minmax(0,1fr))_auto]">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger
key={section.name}
value={section.name}
className={
section.name === 'basic'
? 'px-4 text-muted-foreground data-[state=active]:text-foreground'
: undefined
}
>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
<div className="h-full p-0 flex flex-col">
<Form {...form}>
<form
id="agent-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<Tabs
value={activeSection}
onValueChange={(value) =>
setActiveSection(value as AgentConfigSection)
}
>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger key={section.name} value={section.name}>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
</div>
</Tabs>
</nav>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
{activeSection === 'runner' && (
<div className="space-y-6">
{runnerSelectorStage
? renderDynamicStage(runnerSelectorStage)
: !runnerConfigSchema && (
<Card>
<CardHeader>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription>
{t('agents.noRunnerMetadata')}
</CardDescription>
</CardHeader>
</Card>
)}
</div>
</Tabs>
</nav>
)}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full min-w-0 max-w-5xl space-y-6 pb-8">
{activeSection === 'runner' && (
<div className="space-y-6">
{runnerSelectorStage
? renderDynamicStage(runnerSelectorStage)
: !runnerConfigSchema && (
<Card>
<CardHeader>
<CardTitle>
{t('agents.runnerSettings')}
</CardTitle>
<CardDescription>
{t('agents.noRunnerMetadata')}
</CardDescription>
</CardHeader>
</Card>
)}
</div>
)}
{activeSection === 'runner_config' && (
<div className="space-y-6">
{activeRunnerStage ? (
renderDynamicStage(activeRunnerStage)
) : (
<Card>
<CardHeader>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription>
{t('agents.noRunnerMetadata')}
</CardDescription>
</CardHeader>
</Card>
)}
</div>
)}
{activeSection === 'events' && (
<Card>
<CardHeader>
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
<CardDescription>
{t('agents.bindableEventsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
<FormControl>
<Textarea
{...field}
className="min-h-32 font-mono text-sm"
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{activeSection === 'basic' && (
<div className="space-y-6">
<Card className="border-destructive/50">
{activeSection === 'runner_config' && (
<div className="space-y-6">
{activeRunnerStage ? (
renderDynamicStage(activeRunnerStage)
) : (
<Card>
<CardHeader>
<CardTitle className="text-destructive">
{t('agents.dangerZone')}
</CardTitle>
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
<CardDescription>
{t('agents.dangerZoneDescription')}
{t('agents.noRunnerMetadata')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('agents.deleteAgentAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('agents.deleteAgentHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
)}
</div>
)}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('agents.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
{activeSection === 'events' && (
<Card>
<CardHeader>
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
<CardDescription>
{t('agents.bindableEventsDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="supported_event_patterns_text"
render={({ field }) => (
<FormItem>
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
<FormControl>
<Textarea
{...field}
className="min-h-32 font-mono text-sm"
placeholder={'*\nmessage.received\ngroup.*'}
/>
</FormControl>
<FormDescription>
{t('agents.supportedEventsDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
)}
</div>
</div>
</form>
</Form>
</div>
);
}
@@ -23,6 +23,7 @@ export interface ProcessorDetailStatus {
interface ProcessorDetailWorkbenchProps {
title: string;
titleAction?: ReactNode;
headerActions?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
saveFormId: string;
@@ -43,6 +44,7 @@ interface ProcessorDetailWorkbenchProps {
export default function ProcessorDetailWorkbench({
title,
titleAction,
headerActions,
status,
saveLabel,
saveFormId,
@@ -138,6 +140,7 @@ export default function ProcessorDetailWorkbench({
{saveLabel}
</Button>
)}
{activeView === 'workbench' && headerActions}
</div>
</div>
@@ -50,9 +50,8 @@ test.describe('processor detail workbench', () => {
);
await expect(flow.getByRole('tab').nth(1)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(2)).toContainText('Local Agent');
const agentManagement = flow.getByRole('tab').nth(3);
await expect(agentManagement).toContainText('Management');
await expect(agentManagement).toHaveClass(/text-muted-foreground/);
await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0);
await expect(
page.getByRole('heading', { name: /agent-workbench/ }),
@@ -60,6 +59,15 @@ test.describe('processor detail workbench', () => {
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save' });
const deleteButton = page.getByRole('button', { name: 'Delete' });
await expect(saveButton).toBeVisible();
await expect(deleteButton).toBeVisible();
const saveBox = await saveButton.boundingBox();
const deleteBox = await deleteButton.boundingBox();
expect(saveBox).not.toBeNull();
expect(deleteBox).not.toBeNull();
expect(deleteBox!.x).toBeGreaterThan(saveBox!.x);
await expect(configPanel.getByLabel('Name')).toHaveCount(0);
await expect(configPanel.getByLabel('Icon')).toHaveCount(0);
await expect(configPanel.getByLabel('Description')).toHaveCount(0);
@@ -83,12 +91,14 @@ test.describe('processor detail workbench', () => {
await expect(
configPanel.getByText('Local Agent', { exact: true }).last(),
).toBeVisible();
await agentManagement.click();
await expect(configPanel.getByText('Danger Zone')).toBeVisible();
await expect(configPanel.getByText('Availability')).toHaveCount(0);
await expect(
configPanel.getByRole('switch', { name: 'Enable Agent' }),
).toHaveCount(0);
await deleteButton.click();
const deleteDialog = page.getByRole('dialog');
await expect(deleteDialog).toContainText(
'Are you sure you want to delete this Agent?',
);
await deleteDialog.getByRole('button', { name: 'Cancel' }).click();
await expect(deleteDialog).toHaveCount(0);
});
test('agent saves edits before debugging and shows the real output', async ({
@@ -130,6 +140,27 @@ test.describe('processor detail workbench', () => {
expect(requests).toEqual(['save', 'debug']);
});
test('agent deletion is confirmed from the header and returns to the list', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/agents?id=agent-workbench');
const deleteRequest = page.waitForRequest(
(request) =>
request.method() === 'DELETE' &&
new URL(request.url()).pathname === '/api/v1/agents/agent-workbench',
);
await page.getByRole('button', { name: 'Delete' }).click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Confirm Delete' })
.click();
await deleteRequest;
await expect(page).toHaveURL(/\/home\/agents$/);
});
test('agent turns runner failures into an actionable message', async ({
page,
}) => {