feat(web): edit entity details from page titles

This commit is contained in:
RockChinQ
2026-08-25 22:31:23 +08:00
parent 69ca7e21cd
commit db2a9155f8
18 changed files with 904 additions and 388 deletions
+11
View File
@@ -692,6 +692,17 @@ class BotService:
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found')
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
if not runtime_fields.intersection(update_data):
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
if 'name' in update_data:
runtime_bot.bot_entity.name = update_data['name']
if 'description' in update_data:
runtime_bot.bot_entity.description = update_data['description']
return
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
# select from db
@@ -443,6 +443,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
# Mock pipeline query - not updating pipeline
ap.persistence_mgr.execute_async = AsyncMock()
@@ -473,6 +474,7 @@ class TestBotServiceUpdateBot:
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=None),
remove_bot=AsyncMock(),
load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
)
@@ -496,6 +498,29 @@ class TestBotServiceUpdateBot:
assert 'use_pipeline_uuid' not in update_params
assert 'use_pipeline_name' not in update_params
async def test_basic_info_update_does_not_restart_platform_adapter(self):
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1)))
runtime_entity = SimpleNamespace(name='Old name', description='Old description')
runtime_bot = SimpleNamespace(bot_entity=runtime_entity)
ap.platform_mgr = SimpleNamespace(
get_bot_by_uuid=AsyncMock(return_value=runtime_bot),
remove_bot=AsyncMock(),
load_bot=AsyncMock(),
)
service = BotService(ap)
await service.update_bot(
WORKSPACE_UUID,
'test-uuid',
{'name': 'New name', 'description': 'New description'},
)
assert runtime_entity.name == 'New name'
assert runtime_entity.description == 'New description'
ap.platform_mgr.remove_bot.assert_not_awaited()
ap.platform_mgr.load_bot.assert_not_awaited()
class TestBotServiceDeleteBot:
"""Tests for delete_bot method."""
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Agent } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
@@ -28,6 +33,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
@@ -88,10 +94,33 @@ export default function AgentDetailContent({ id }: { id: string }) {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateAgent(id, values);
setAgent((current) => (current ? { ...current, ...values } : current));
agentFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('agents.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
throw error;
}
}
return (
<>
<ProcessorDetailWorkbench
key={id}
title={`${agent.emoji || '🤖'} ${agent.name}`}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
@@ -141,5 +170,17 @@ export default function AgentDetailContent({ id }: { id: string }) {
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: agent.name,
description: agent.description || '',
emoji: agent.emoji || '🤖',
}}
defaultEmoji="🤖"
onSave={saveBasicInfo}
/>
</>
);
}
@@ -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, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
import { Bot, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import {
@@ -25,9 +25,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
Card,
CardContent,
@@ -68,11 +66,19 @@ interface AgentFormComponentProps {
}
export type AgentConfigSection =
'events' | 'runner' | 'runner_config' | 'basic';
| 'events'
| 'runner'
| 'runner_config'
| 'basic';
export interface AgentFormHandle {
openSection: (section: AgentConfigSection) => void;
save: () => Promise<boolean>;
syncBasicInfo: (values: {
name: string;
description: string;
emoji?: string;
}) => void;
}
function isRequiredRunnerValueMissing(value: unknown): boolean {
@@ -266,8 +272,8 @@ function AgentFormComponent(
}> = [
{
name: 'basic',
label: t('agents.basicInfo'),
icon: Info,
label: t('common.management'),
icon: Power,
},
{
name: 'events',
@@ -503,6 +509,24 @@ function AgentFormComponent(
ref,
() => ({
openSection: setActiveSection,
syncBasicInfo(values) {
form.setValue('basic', {
...form.getValues('basic'),
name: values.name,
description: values.description,
emoji: values.emoji || '🤖',
});
if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '🤖',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
},
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false;
@@ -634,62 +658,12 @@ function AgentFormComponent(
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardTitle>{t('agents.availability')}</CardTitle>
<CardDescription>
{t('agents.basicInfoDescription')}
{t('agents.availabilityDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
ariaLabel={t('common.icon')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<CardContent>
<FormField
control={form.control}
name="basic.enabled"
+58 -13
View File
@@ -19,7 +19,9 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import BotForm, {
BotFormHandle,
} from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
@@ -30,6 +32,11 @@ import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
@@ -55,8 +62,11 @@ export default function BotDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
const botFormRef = useRef<BotFormHandle>(null);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
@@ -69,6 +79,7 @@ export default function BotDetailContent({ id }: { id: string }) {
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
@@ -80,16 +91,10 @@ export default function BotDetailContent({ id }: { id: string }) {
const prev = botEnabled;
setBotEnabled(checked);
try {
// Fetch current bot data to send a complete update
const res = await httpClient.getBot(id);
const bot = res.bot;
await httpClient.updateBot(id, {
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapter_config: bot.adapter_config,
enable: checked,
});
await httpClient.updateBot(id, { enable: checked });
setBot((current) =>
current ? { ...current, enable: checked } : current,
);
refreshBots();
} catch {
setBotEnabled(prev);
@@ -102,6 +107,7 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
});
refreshBots();
@@ -117,6 +123,26 @@ export default function BotDetailContent({ id }: { id: string }) {
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateBot(id, {
name: values.name,
description: values.description,
});
setBot((current) => (current ? { ...current, ...values } : current));
botFormRef.current?.syncBasicInfo(values);
await refreshBots();
toast.success(t('bots.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('bots.saveError') + message);
throw error;
}
}
function confirmDelete() {
httpClient
.deleteBot(id)
@@ -166,8 +192,15 @@ export default function BotDetailContent({ id }: { id: string }) {
<div className="flex h-full min-w-0 flex-col">
{/* Sticky Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold">
{bot?.name || t('bots.editBot')}
</h1>
{canManage && (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
)}
</div>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
@@ -258,6 +291,7 @@ export default function BotDetailContent({ id }: { id: string }) {
<div className="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-6 pb-8">
<fieldset className="contents" disabled={!canManage}>
<BotForm
ref={botFormRef}
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
@@ -344,6 +378,17 @@ export default function BotDetailContent({ id }: { id: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: bot?.name || '',
description: bot?.description || '',
}}
showEmoji={false}
onSave={saveBasicInfo}
/>
</>
);
}
@@ -1,4 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import i18n from 'i18next';
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
import {
@@ -79,17 +86,21 @@ const getFormSchema = (t: (key: string) => string) =>
.optional(),
});
export default function BotForm({
initBotId,
onFormSubmit,
onNewBotCreated,
onDirtyChange,
}: {
export interface BotFormHandle {
syncBasicInfo: (values: { name: string; description: string }) => void;
}
interface BotFormProps {
initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
}
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
{ initBotId, onFormSubmit, onNewBotCreated, onDirtyChange },
ref,
) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
@@ -174,6 +185,19 @@ export default function BotForm({
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.reset(
{
...form.getValues(),
name: values.name,
description: values.description,
},
{ keepDirtyValues: true },
);
},
}));
useEffect(() => {
setBotFormValues();
}, []);
@@ -416,7 +440,7 @@ export default function BotForm({
className="w-full min-w-0 max-w-full space-y-6"
disabled={isLoading}
>
{/* Card 1: Basic Information */}
{!initBotId && (
<Card>
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
@@ -456,6 +480,7 @@ export default function BotForm({
/>
</CardContent>
</Card>
)}
{/* Card 2: Adapter Configuration */}
<Card>
@@ -688,4 +713,6 @@ export default function BotForm({
</form>
</Form>
);
}
});
export default BotForm;
@@ -0,0 +1,164 @@
import { FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import EmojiPicker from '@/components/ui/emoji-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface EntityBasicInfoValues {
name: string;
description: string;
emoji?: string;
}
interface EntityBasicInfoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
values: EntityBasicInfoValues;
defaultEmoji?: string;
showEmoji?: boolean;
onSave: (values: EntityBasicInfoValues) => Promise<void>;
}
export default function EntityBasicInfoDialog({
open,
onOpenChange,
values,
defaultEmoji,
showEmoji = true,
onSave,
}: EntityBasicInfoDialogProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(values);
const [isSaving, setIsSaving] = useState(false);
const [nameError, setNameError] = useState(false);
useEffect(() => {
if (!open) return;
setDraft({
name: values.name,
description: values.description,
emoji: values.emoji || defaultEmoji,
});
setNameError(false);
}, [defaultEmoji, open, values.description, values.emoji, values.name]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = draft.name.trim();
if (!name) {
setNameError(true);
return;
}
setIsSaving(true);
try {
await onSave({
name,
description: draft.description.trim(),
emoji: showEmoji ? draft.emoji || defaultEmoji : undefined,
});
onOpenChange(false);
} catch {
// The caller presents the entity-specific error message.
} finally {
setIsSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('common.editBasicInfo')}</DialogTitle>
<DialogDescription>
{t(
showEmoji
? 'common.editBasicInfoDescription'
: 'common.editBasicInfoDescriptionNoIcon',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-5">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1 space-y-2">
<Label htmlFor="entity-basic-name">{t('common.name')}</Label>
<Input
id="entity-basic-name"
value={draft.name}
aria-invalid={nameError}
onChange={(event) => {
setDraft((current) => ({
...current,
name: event.target.value,
}));
if (event.target.value.trim()) setNameError(false);
}}
autoFocus
/>
{nameError && (
<p className="text-sm text-destructive">
{t('common.fieldRequired')}
</p>
)}
</div>
{showEmoji && (
<div className="space-y-2">
<Label>{t('common.icon')}</Label>
<EmojiPicker
value={draft.emoji || defaultEmoji}
onChange={(emoji) =>
setDraft((current) => ({ ...current, emoji }))
}
ariaLabel={t('common.icon')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="entity-basic-description">
{t('common.description')}
</Label>
<Input
id="entity-basic-description"
value={draft.description}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,34 @@
import { Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
export default function EntityTitleEditButton({
onClick,
}: {
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground"
aria-label={t('common.editBasicInfo')}
onClick={onClick}
>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.editBasicInfo')}</TooltipContent>
</Tooltip>
);
}
@@ -22,6 +22,7 @@ export interface ProcessorDetailStatus {
interface ProcessorDetailWorkbenchProps {
title: string;
titleAction?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
saveFormId: string;
@@ -41,6 +42,7 @@ interface ProcessorDetailWorkbenchProps {
export default function ProcessorDetailWorkbench({
title,
titleAction,
status,
saveLabel,
saveFormId,
@@ -67,6 +69,7 @@ export default function ProcessorDetailWorkbench({
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{titleAction}
{status && (
<Tooltip>
<TooltipTrigger asChild>
@@ -139,7 +142,10 @@ export default function ProcessorDetailWorkbench({
</div>
{activeView === 'monitoring' && monitoring ? (
<section className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4">
<section
aria-label={monitoring.label}
className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4"
>
{monitoring.content}
</section>
) : (
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import PipelineFormComponent, {
PipelineFormHandle,
@@ -7,9 +8,15 @@ import PipelineFormComponent, {
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { useCurrentWorkspace } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Pipeline } from '@/app/infra/entities/api';
export default function PipelineDetailContent({
id,
@@ -44,13 +51,47 @@ export default function PipelineDetailContent({
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const pipeline = pipelines.find((item) => item.id === id);
const sidebarPipeline = pipelines.find((item) => item.id === id);
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
httpClient.getPipeline(id).then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
});
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
function handleFinish() {
refreshPipelines();
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updatePipeline(id, values);
setPipelineDetails((current) =>
current
? { ...current, ...values }
: ({ ...values, config: {} } as Pipeline),
);
pipelineFormRef.current?.syncBasicInfo(values);
await refreshPipelines();
toast.success(t('pipelines.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('pipelines.saveError') + message);
throw error;
}
}
function handleNewPipelineCreated(newPipelineId: string) {
refreshPipelines();
navigate(`${routeBase}?id=${encodeURIComponent(newPipelineId)}`);
@@ -97,10 +138,23 @@ export default function PipelineDetailContent({
}
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
sidebarPipeline?.name ||
t('pipelines.editPipeline');
const pipelineEmoji =
pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️';
return (
<>
<ProcessorDetailWorkbench
key={id}
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
title={`${pipelineEmoji} ${pipelineName}`}
titleAction={
canManage ? (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
) : undefined
}
saveLabel={t('common.save')}
saveFormId="pipeline-form"
canSave={canManage}
@@ -158,5 +212,17 @@ export default function PipelineDetailContent({
: undefined
}
/>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: pipelineName,
description: pipelineDetails?.description || '',
emoji: pipelineEmoji,
}}
defaultEmoji="⚙️"
onSave={saveBasicInfo}
/>
</>
);
}
@@ -73,6 +73,11 @@ interface PipelineFormComponentProps {
export interface PipelineFormHandle {
save: () => Promise<boolean>;
syncBasicInfo: (values: {
name: string;
description: string;
emoji?: string;
}) => void;
}
const PipelineFormComponent = forwardRef<
@@ -137,7 +142,7 @@ const PipelineFormComponent = forwardRef<
const formLabelList: SectionItem[] = isEditMode
? [
{
label: t('pipelines.basicInfo'),
label: t('common.management'),
name: 'basic',
icon: SECTION_ICONS.basic,
},
@@ -367,6 +372,24 @@ const PipelineFormComponent = forwardRef<
}
useImperativeHandle(ref, () => ({
syncBasicInfo(values) {
form.setValue('basic', {
...form.getValues('basic'),
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
});
if (savedSnapshotRef.current) {
const snapshot = JSON.parse(savedSnapshotRef.current) as FormValues;
snapshot.basic = {
...snapshot.basic,
name: values.name,
description: values.description,
emoji: values.emoji || '⚙️',
};
savedSnapshotRef.current = JSON.stringify(snapshot);
}
},
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
@@ -656,19 +679,24 @@ const PipelineFormComponent = forwardRef<
{/* Content panel */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* Basic info section */}
{activeSection === 'basic' && (
<div className="space-y-6">
{/* Basic Information Card */}
<Card>
<CardHeader>
<CardTitle>{t('pipelines.basicInfo')}</CardTitle>
<CardTitle>
{isEditMode
? t('common.management')
: t('pipelines.basicInfo')}
</CardTitle>
<CardDescription>
{t('pipelines.basicInfoDescription')}
{isEditMode
? t('pipelines.managementDescription')
: t('pipelines.basicInfoDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name and Emoji in same row */}
{!isEditMode && (
<>
<div className="flex gap-4 items-start">
<FormField
control={form.control}
@@ -677,10 +705,15 @@ const PipelineFormComponent = forwardRef<
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-destructive">*</span>
<span className="text-destructive">
*
</span>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
<Input
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -709,16 +742,22 @@ const PipelineFormComponent = forwardRef<
name="basic.description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormLabel>
{t('common.description')}
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
<Input
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* Copy pipeline (edit mode only) */}
{isEditMode && (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
+5 -2
View File
@@ -329,7 +329,10 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/pipelines', pipeline);
}
public updatePipeline(uuid: string, pipeline: Pipeline): Promise<object> {
public updatePipeline(
uuid: string,
pipeline: Partial<Pipeline>,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}`, pipeline);
}
@@ -489,7 +492,7 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/platform/bots', bot);
}
public updateBot(uuid: string, bot: Bot): Promise<object> {
public updateBot(uuid: string, bot: Partial<Bot>): Promise<object> {
return this.put(`/api/v1/platform/bots/${uuid}`, bot);
}
+8
View File
@@ -42,6 +42,10 @@ const enUS = {
joinDiscord: 'Join our Discord',
create: 'Create',
edit: 'Edit',
editBasicInfo: 'Edit basic information',
editBasicInfoDescription: 'Change the name, description, and icon.',
editBasicInfoDescriptionNoIcon: 'Change the name and description.',
management: 'Management',
delete: 'Delete',
add: 'Add',
select: 'Select',
@@ -688,6 +692,9 @@ const enUS = {
messageEventsOnly: 'Message events only',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the name, icon, description and enabled state',
availability: 'Availability',
availabilityDescription:
'Control whether this Agent can receive and process events.',
runnerSettings: 'Runner',
advanced: 'Advanced',
bindableEvents: 'Bindable Event Range',
@@ -1233,6 +1240,7 @@ const enUS = {
earliestEdited: 'Earliest Edited',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the pipeline name, icon and description',
managementDescription: 'Copy or delete this pipeline.',
aiCapabilities: 'AI',
triggerConditions: 'Trigger',
safetyControls: 'Safety',
+8
View File
@@ -43,6 +43,10 @@ const jaJP = {
joinDiscord: 'Discord に参加',
create: '作成',
edit: '編集',
editBasicInfo: '基本情報を編集',
editBasicInfoDescription: '名前、説明、アイコンを変更します。',
editBasicInfoDescriptionNoIcon: '名前と説明を変更します。',
management: '管理',
delete: '削除',
add: '追加',
select: '選択してください',
@@ -703,6 +707,9 @@ const jaJP = {
messageEventsOnly: 'メッセージイベントのみ',
basicInfo: '基本情報',
basicInfoDescription: '名前、アイコン、説明、有効状態を設定します',
availability: '有効状態',
availabilityDescription:
'この Agent がイベントを受信して処理できるかを制御します。',
runnerSettings: 'Runner',
advanced: '詳細',
bindableEvents: '紐付け可能なイベント範囲',
@@ -1198,6 +1205,7 @@ const jaJP = {
earliestEdited: '最古編集',
basicInfo: '基本情報',
basicInfoDescription: 'パイプラインの名前、アイコン、説明を設定',
managementDescription: 'このパイプラインを複製または削除します。',
aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件',
safetyControls: '安全制御',
+7
View File
@@ -41,6 +41,10 @@ const zhHans = {
joinDiscord: '加入 Discord 社区',
create: '创建',
edit: '编辑',
editBasicInfo: '编辑基本信息',
editBasicInfoDescription: '修改名称、描述和图标。',
editBasicInfoDescriptionNoIcon: '修改名称和描述。',
management: '管理',
delete: '删除',
add: '添加',
select: '请选择',
@@ -658,6 +662,8 @@ const zhHans = {
messageEventsOnly: '仅支持消息事件',
basicInfo: '基础信息',
basicInfoDescription: '设置名称、图标、描述和启用状态',
availability: '启用状态',
availabilityDescription: '控制此 Agent 是否可以接收并处理事件。',
runnerSettings: '运行器',
advanced: '高级',
bindableEvents: '可绑定事件范围',
@@ -1175,6 +1181,7 @@ const zhHans = {
earliestEdited: '最早编辑',
basicInfo: '基础信息',
basicInfoDescription: '设置流水线名称、图标和描述',
managementDescription: '复制或删除此流水线。',
aiCapabilities: 'AI 能力',
triggerConditions: '触发条件',
safetyControls: '安全控制',
+96 -84
View File
@@ -116,7 +116,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page.getByText('No logs yet')).toBeVisible();
await page.goto('/home/agents?id=pipeline-1');
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
@@ -144,15 +144,21 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
await page
.locator('input[name="description"]')
await expect(
page.getByRole('heading', { name: 'Support Bot' }),
).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const botInfoDialog = page.getByRole('dialog');
await expect(botInfoDialog.getByLabel('Icon')).toHaveCount(0);
await botInfoDialog.getByLabel('Name').fill('Support Bot Updated');
await botInfoDialog
.getByLabel('Description')
.fill('Answers customer support questions with context.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Answers customer support questions with context.',
);
await botInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: 'Support Bot Updated' }),
).toBeVisible();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
@@ -176,18 +182,18 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
await page.reload();
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
'Escalation Pipeline',
);
await page
.locator('input[name="basic.description"]')
await expect(
page.getByRole('heading', { name: /Escalation Pipeline/ }),
).toBeVisible();
await expect(page.locator('input[name="basic.name"]')).toHaveCount(0);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const pipelineInfoDialog = page.getByRole('dialog');
await pipelineInfoDialog
.getByLabel('Description')
.fill('Routes urgent customer issues to operators.');
await save(page);
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
'Routes urgent customer issues to operators.',
);
await pipelineInfoDialog.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Management' }).click();
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
@@ -204,8 +210,10 @@ test.describe('frontend CRUD smoke flows', () => {
await page.goto('/home/agents?id=pipeline-ai');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.getByRole('button', { name: /^AI$/ }).click();
await expect(
page.getByRole('heading', { name: /pipeline-ai/ }),
).toBeVisible();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(page.getByText('Runtime')).toBeVisible();
await expect(
@@ -512,7 +520,9 @@ test.describe('bot advanced flows', () => {
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
// Switch to Logs tab
await page.getByRole('tab', { name: /Logs/ }).click();
@@ -530,7 +540,9 @@ test.describe('bot advanced flows', () => {
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
});
test('save button is disabled when form is clean', async ({ page }) => {
@@ -541,23 +553,22 @@ test.describe('bot advanced flows', () => {
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Clean Form Bot');
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Reload the persisted record so post-create initialization has completed.
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'Clean Form Bot',
);
await expect(
page.getByRole('heading', { name: 'Clean Form Bot' }),
).toBeVisible();
// After loading, save button should be disabled (form is clean)
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled();
// Edit the form
await page.locator('input[name="description"]').fill('New description');
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Description').fill('New description');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(saveButton).toBeDisabled();
});
@@ -593,7 +604,7 @@ test.describe('pipeline advanced flows', () => {
});
await page.goto('/home/agents?id=pipeline-scope');
await page.getByRole('button', { name: /^AI$/ }).click();
await page.getByRole('tab', { name: /^AI$/ }).click();
await expect(
page.getByRole('button', { name: 'Edit tools' }),
).toBeVisible();
@@ -621,22 +632,26 @@ test.describe('pipeline advanced flows', () => {
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
// Skip Debug tab as it requires WebSocket connection
await page.getByRole('tab', { name: /Dashboard/ }).click();
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
'data-state',
'active',
);
await page
.getByRole('button', { name: 'Dashboard', exact: true })
.last()
.click();
await expect(page.getByRole('region', { name: /Dashboard/ })).toBeVisible();
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page
.getByRole('button', { name: 'Dashboard', exact: true })
.last()
.click();
await expect(
page.getByRole('region', { name: 'Configuration' }),
).toBeVisible();
});
test('save button reflects form dirty state', async ({ page }) => {
@@ -648,20 +663,16 @@ test.describe('pipeline advanced flows', () => {
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
await submit(page);
// Wait for the page to fully load and form to reset
await page.waitForTimeout(500);
// Edit the form - use the name field which definitely triggers dirty state
await page
.locator('input[name="basic.name"]')
.fill('Dirty Form Pipeline Updated');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
// Wait for save to complete
await page.waitForTimeout(500);
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Edit basic information' }).click();
const infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Dirty Form Pipeline Updated');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Dirty Form Pipeline Updated/ }),
).toBeVisible();
await expect(saveButton).toBeDisabled();
});
test('shows validation error when pipeline name is empty', async ({
@@ -705,7 +716,8 @@ test.describe('agent runner resource selectors', () => {
});
await page.goto('/home/agents?id=agent-scope');
await page.getByRole('button', { name: /^Runner$/ }).click();
await page.getByRole('tab', { name: /^Runner$/ }).click();
await page.getByRole('tab', { name: 'Local Agent' }).click();
await page.getByRole('button', { name: 'Edit tools' }).click();
const dialog = page.getByRole('dialog');
@@ -747,16 +759,16 @@ test.describe('agent and pipeline save concurrency', () => {
await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
const eventPatterns = page.getByLabel('Event Range');
await expect(eventPatterns).toBeVisible();
await nameInput.fill('Submitted Agent');
await eventPatterns.fill('message.received');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the agent save is pending');
await eventPatterns.fill('group.*');
await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
@@ -764,15 +776,13 @@ test.describe('agent and pipeline save concurrency', () => {
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Agent',
description: '',
supported_event_patterns: ['message.received'],
});
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Agent',
description: 'Edited while the agent save is pending',
supported_event_patterns: ['group.*'],
});
await expect(saveButton).toBeDisabled();
});
@@ -787,35 +797,35 @@ test.describe('agent and pipeline save concurrency', () => {
);
await page.goto('/home/agents?id=pipeline-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await nameInput.fill('Submitted Pipeline');
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
let infoDialog = page.getByRole('dialog');
await infoDialog.getByLabel('Name').fill('Submitted Pipeline');
const dialogSaveButton = infoDialog.getByRole('button', { name: 'Save' });
await dialogSaveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the pipeline save is pending');
await forceFormSubmit(page, '#pipeline-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
await expect(
infoDialog.getByRole('button', { name: 'Saving...' }),
).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
await expect(infoDialog).toHaveCount(0);
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Pipeline',
description: '',
});
await saveButton.click();
await page.getByRole('button', { name: 'Edit basic information' }).click();
infoDialog = page.getByRole('dialog');
await infoDialog
.getByLabel('Description')
.fill('Edited in the next basic information save');
await infoDialog.getByRole('button', { name: 'Save' }).click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Pipeline',
description: 'Edited while the pipeline save is pending',
description: 'Edited in the next basic information save',
});
await expect(saveButton).toBeDisabled();
await expect(infoDialog).toHaveCount(0);
});
});
@@ -838,7 +848,9 @@ test.describe('cross-resource flows', () => {
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for form to fully load
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
await expect(
page.getByRole('heading', { name: 'Bound Bot' }),
).toBeVisible();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
+18 -3
View File
@@ -647,7 +647,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const botId = decodeURIComponent(botMatch[1]);
if (method === 'PUT') {
const bot = makeBot(state, parseJsonBody(route), botId);
const current = state.bots.find((item) => item.uuid === botId);
const bot = makeBot(
state,
{ ...(current || {}), ...parseJsonBody(route) },
botId,
);
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
return fulfillJson(route, {});
}
@@ -729,7 +734,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const agentId = decodeURIComponent(agentMatch[1]);
if (method === 'PUT') {
const agent = makePipeline(state, parseJsonBody(route), agentId);
const current = state.pipelines.find((item) => item.uuid === agentId);
const agent = makePipeline(
state,
{ ...(current || {}), ...parseJsonBody(route) },
agentId,
);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== agentId),
agent,
@@ -789,7 +799,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
if (method === 'PUT') {
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
const current = state.pipelines.find((item) => item.uuid === pipelineId);
const pipeline = makePipeline(
state,
{ ...(current || {}), ...parseJsonBody(route) },
pipelineId,
);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipelineId),
pipeline,
@@ -45,26 +45,32 @@ test.describe('processor detail workbench', () => {
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText(
'Basic Information',
);
await expect(flow.getByRole('tab').nth(0)).toContainText('Management');
await expect(flow.getByRole('tab').nth(1)).toContainText(
'Bindable Event Range',
);
await expect(flow.getByRole('tab').nth(2)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(3)).toContainText('Local Agent');
await expect(configPanel.getByLabel('Name')).toBeVisible();
await expect(configPanel.getByLabel('Icon')).toBeVisible();
await expect(configPanel.getByLabel('Description')).toBeVisible();
await expect(
page.getByRole('heading', { name: /agent-workbench/ }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Edit basic information' }),
).toBeVisible();
await expect(configPanel.getByLabel('Name')).toHaveCount(0);
await expect(configPanel.getByLabel('Icon')).toHaveCount(0);
await expect(configPanel.getByLabel('Description')).toHaveCount(0);
const runnerStatus = page.getByRole('status', { name: 'Runner ready' });
await expect(runnerStatus).toBeVisible();
await runnerStatus.hover();
await expect(
page.getByText(
page
.getByText(
'Local Agent is registered and the plugin runtime is connected.',
),
)
.last(),
).toBeVisible();
await flow.getByRole('tab').nth(1).click();
@@ -99,11 +105,18 @@ test.describe('processor detail workbench', () => {
});
await page.goto('/home/agents?id=agent-workbench');
await page.getByLabel('Description').fill('Updated before debugging');
await page.getByRole('button', { name: 'Edit basic information' }).click();
const basicInfoDialog = page.getByRole('dialog');
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
await basicInfoDialog
.getByLabel('Description')
.fill('Updated before debugging');
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(basicInfoDialog).toHaveCount(0);
await page
.getByRole('textbox', { name: 'Conversation input' })
.fill('Hello');
await page.getByRole('button', { name: 'Save and run' }).click();
await page.getByRole('button', { name: 'Run test' }).click();
await expect(page.getByText('Mock Agent response')).toBeVisible();
expect(requests).toEqual(['save', 'debug']);
@@ -186,6 +199,24 @@ test.describe('processor detail workbench', () => {
page.getByText('Conversation reset successfully'),
).toBeVisible();
await expect(
page.getByRole('heading', { name: /pipeline-workbench/ }),
).toBeVisible();
await expect(configPanel.locator('input[name="basic.name"]')).toHaveCount(
0,
);
await page.getByRole('button', { name: 'Edit basic information' }).click();
const basicInfoDialog = page.getByRole('dialog');
await expect(basicInfoDialog.getByLabel('Icon')).toBeVisible();
await basicInfoDialog.getByLabel('Name').fill('Renamed Pipeline');
await basicInfoDialog
.getByLabel('Description')
.fill('Updated from the title dialog.');
await basicInfoDialog.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByRole('heading', { name: /Renamed Pipeline/ }),
).toBeVisible();
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
expect(debugBox).not.toBeNull();