diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 92ea36fa1..fba19bab5 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -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 diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index f8a87c196..03dd322bd 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -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.""" diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index ec69b5700..2d682a335 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -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( null, ); @@ -88,58 +94,93 @@ export default function AgentDetailContent({ id }: { id: string }) { return ; } + 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 ( - - { - if (updatedAgent) { - setAgent((current) => - current ? { ...current, ...updatedAgent } : current, - ); + <> + setBasicInfoOpen(true)} /> + ) : undefined + } + status={runnerStatus} + saveLabel={t('common.save')} + saveFormId="agent-form" + canSave={canManage} + isDirty={formDirty} + isSaving={formSaving} + configTitle={t('pipelines.configuration')} + configContent={ +
+ { + if (updatedAgent) { + setAgent((current) => + current ? { ...current, ...updatedAgent } : current, + ); + } + refreshPipelines(); + }} + onDeleted={() => { + refreshPipelines(); + navigate('/home/agents'); + }} + onDirtyChange={setFormDirty} + onSavingChange={setFormSaving} + onRunnerStatusChange={setRunnerStatus} + /> +
+ } + debugTitle={canOperate ? t('agents.debugTab') : undefined} + debugContent={ + canOperate ? ( + agentFormRef.current?.save() ?? false} + onOpenRunnerConfig={() => + agentFormRef.current?.openSection('runner_config') } - refreshPipelines(); - }} - onDeleted={() => { - refreshPipelines(); - navigate('/home/agents'); - }} - onDirtyChange={setFormDirty} - onSavingChange={setFormSaving} - onRunnerStatusChange={setRunnerStatus} - /> - - } - debugTitle={canOperate ? t('agents.debugTab') : undefined} - debugContent={ - canOperate ? ( - agentFormRef.current?.save() ?? false} - onOpenRunnerConfig={() => - agentFormRef.current?.openSection('runner_config') - } - supportedEventPatterns={ - agent.supported_event_patterns ?? - agent.capability?.supported_event_patterns ?? ['*'] - } - /> - ) : undefined - } - unsavedLabel={t('pipelines.unsavedChanges')} - /> + supportedEventPatterns={ + agent.supported_event_patterns ?? + agent.capability?.supported_event_patterns ?? ['*'] + } + /> + ) : undefined + } + unsavedLabel={t('pipelines.unsavedChanges')} + /> + + ); } diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index f1eda3ac1..43134ebcd 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -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; + 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(
- {t('agents.basicInfo')} + {t('agents.availability')} - {t('agents.basicInfoDescription')} + {t('agents.availabilityDescription')} - -
- ( - - - {t('common.name')} - * - - - - - - - )} - /> - ( - - {t('common.icon')} - - - - - - )} - /> -
- - ( - - {t('common.description')} - - - - - - )} - /> - + (null); const [isRefreshingSessions, setIsRefreshingSessions] = useState(false); const sessionMonitorRef = useRef(null); + const botFormRef = useRef(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 }) {
{/* Sticky Header: title + enable switch + save button */}
-
-

{t('bots.editBot')}

+
+
+

+ {bot?.name || t('bots.editBot')} +

+ {canManage && ( + setBasicInfoOpen(true)} /> + )} +
{enableLoaded && (
+ + ); } diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 491c004b9..4fff685ba 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -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>) => void; onNewBotCreated: (botId: string) => void; onDirtyChange?: (dirty: boolean) => void; -}) { +} + +const BotForm = forwardRef(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,46 +440,47 @@ export default function BotForm({ className="w-full min-w-0 max-w-full space-y-6" disabled={isLoading} > - {/* Card 1: Basic Information */} - - - {t('bots.basicInfo')} - - {t('bots.basicInfoDescription')} - - - - ( - - - {t('bots.botName')} - * - - - - - - - )} - /> - ( - - {t('bots.botDescription')} - - - - - - )} - /> - - + {!initBotId && ( + + + {t('bots.basicInfo')} + + {t('bots.basicInfoDescription')} + + + + ( + + + {t('bots.botName')} + * + + + + + + + )} + /> + ( + + {t('bots.botDescription')} + + + + + + )} + /> + + + )} {/* Card 2: Adapter Configuration */} @@ -688,4 +713,6 @@ export default function BotForm({ ); -} +}); + +export default BotForm; diff --git a/web/src/app/home/components/entity-basic-info/EntityBasicInfoDialog.tsx b/web/src/app/home/components/entity-basic-info/EntityBasicInfoDialog.tsx new file mode 100644 index 000000000..cc75e4bad --- /dev/null +++ b/web/src/app/home/components/entity-basic-info/EntityBasicInfoDialog.tsx @@ -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; +} + +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) { + 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 ( + + +
+ + {t('common.editBasicInfo')} + + {t( + showEmoji + ? 'common.editBasicInfoDescription' + : 'common.editBasicInfoDescriptionNoIcon', + )} + + + +
+
+
+ + { + setDraft((current) => ({ + ...current, + name: event.target.value, + })); + if (event.target.value.trim()) setNameError(false); + }} + autoFocus + /> + {nameError && ( +

+ {t('common.fieldRequired')} +

+ )} +
+ + {showEmoji && ( +
+ + + setDraft((current) => ({ ...current, emoji })) + } + ariaLabel={t('common.icon')} + /> +
+ )} +
+ +
+ + + setDraft((current) => ({ + ...current, + description: event.target.value, + })) + } + /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/web/src/app/home/components/entity-basic-info/EntityTitleEditButton.tsx b/web/src/app/home/components/entity-basic-info/EntityTitleEditButton.tsx new file mode 100644 index 000000000..ccc649b10 --- /dev/null +++ b/web/src/app/home/components/entity-basic-info/EntityTitleEditButton.tsx @@ -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 ( + + + + + {t('common.editBasicInfo')} + + ); +} diff --git a/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx b/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx index 733046242..072dec3e8 100644 --- a/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx +++ b/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx @@ -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({

{title}

+ {titleAction} {status && ( @@ -139,7 +142,10 @@ export default function ProcessorDetailWorkbench({
{activeView === 'monitoring' && monitoring ? ( -
+
{monitoring.content}
) : ( diff --git a/web/src/app/home/pipelines/PipelineDetailContent.tsx b/web/src/app/home/pipelines/PipelineDetailContent.tsx index 85d6a4524..fb08c28f9 100644 --- a/web/src/app/home/pipelines/PipelineDetailContent.tsx +++ b/web/src/app/home/pipelines/PipelineDetailContent.tsx @@ -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(null); const pipelineFormRef = useRef(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,66 +138,91 @@ export default function PipelineDetailContent({ } // ==================== Edit Mode ==================== + const pipelineName = + pipelineDetails?.name || + sidebarPipeline?.name || + t('pipelines.editPipeline'); + const pipelineEmoji = + pipelineDetails?.emoji || sidebarPipeline?.emoji || '⚙️'; + return ( - - navigate(routeBase)} - onDirtyChange={setFormDirty} - onSavingChange={setFormSaving} - /> -
- } - debugTitle={canOperate ? t('pipelines.debugChat') : undefined} - debugConnected={canOperate ? isWebSocketConnected : undefined} - debugConnectedLabel={t('pipelines.debugDialog.connected')} - debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')} - debugContent={ - canOperate ? ( - pipelineFormRef.current?.save() ?? false} - onConnectionStatusChange={setIsWebSocketConnected} - /> - ) : undefined - } - unsavedLabel={t('pipelines.unsavedChanges')} - monitoring={ - canViewMonitoring - ? { - label: t('pipelines.monitoring.title'), - content: ( - { - navigate('/home/monitoring'); - }} - /> - ), - } - : undefined - } - /> + <> + setBasicInfoOpen(true)} /> + ) : undefined + } + saveLabel={t('common.save')} + saveFormId="pipeline-form" + canSave={canManage} + isDirty={formDirty} + isSaving={formSaving} + configTitle={t('pipelines.configuration')} + configContent={ +
+ navigate(routeBase)} + onDirtyChange={setFormDirty} + onSavingChange={setFormSaving} + /> +
+ } + debugTitle={canOperate ? t('pipelines.debugChat') : undefined} + debugConnected={canOperate ? isWebSocketConnected : undefined} + debugConnectedLabel={t('pipelines.debugDialog.connected')} + debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')} + debugContent={ + canOperate ? ( + pipelineFormRef.current?.save() ?? false} + onConnectionStatusChange={setIsWebSocketConnected} + /> + ) : undefined + } + unsavedLabel={t('pipelines.unsavedChanges')} + monitoring={ + canViewMonitoring + ? { + label: t('pipelines.monitoring.title'), + content: ( + { + navigate('/home/monitoring'); + }} + /> + ), + } + : undefined + } + /> + + ); } diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index a743321b0..baa0c886e 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -73,6 +73,11 @@ interface PipelineFormComponentProps { export interface PipelineFormHandle { save: () => Promise; + 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,69 +679,85 @@ const PipelineFormComponent = forwardRef< {/* Content panel */}
- {/* Basic info section */} {activeSection === 'basic' && (
- {/* Basic Information Card */} - {t('pipelines.basicInfo')} + + {isEditMode + ? t('common.management') + : t('pipelines.basicInfo')} + - {t('pipelines.basicInfoDescription')} + {isEditMode + ? t('pipelines.managementDescription') + : t('pipelines.basicInfoDescription')} - {/* Name and Emoji in same row */} -
- ( - - - {t('common.name')} - * - - - - - - - )} - /> - ( - - {t('common.icon')} - - - - - - )} - /> -
+ {!isEditMode && ( + <> +
+ ( + + + {t('common.name')} + + * + + + + + + + + )} + /> + ( + + {t('common.icon')} + + + + + + )} + /> +
- ( - - {t('common.description')} - - - - - - )} - /> + ( + + + {t('common.description')} + + + + + + + )} + /> + + )} - {/* Copy pipeline (edit mode only) */} {isEditMode && (
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index b369aa1b6..9a96a51c5 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -329,7 +329,10 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/pipelines', pipeline); } - public updatePipeline(uuid: string, pipeline: Pipeline): Promise { + public updatePipeline( + uuid: string, + pipeline: Partial, + ): Promise { 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 { + public updateBot(uuid: string, bot: Partial): Promise { return this.put(`/api/v1/platform/bots/${uuid}`, bot); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 70f756ed8..a77ad2f9c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index ea278cbb8..0a9369702 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: '安全制御', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a178737b0..b62aced05 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '安全控制', diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index d3546a287..79c7beeed 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -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(); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 9dfe5ff28..fe7eb5765 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -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, diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index 0468c1608..65a9e3dc9 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -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( - 'Local Agent is registered and the plugin runtime is connected.', - ), + 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();