From ac31f1f006c9ed2754c2c51346651131650bc582 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 25 Aug 2026 12:17:59 +0800 Subject: [PATCH] feat(web): surface agent basics and runner status --- .../app/home/agents/AgentDetailContent.tsx | 13 +- .../agents/components/AgentFormComponent.tsx | 191 +++++++----------- .../ProcessorDetailWorkbench.tsx | 59 +++++- web/src/components/ui/emoji-picker.tsx | 3 + .../e2e/processor-detail-workbench.spec.ts | 24 ++- .../unit/processor-detail-workbench.test.mjs | 4 +- 6 files changed, 164 insertions(+), 130 deletions(-) diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index e4eb2d2ac..133f6459b 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -9,7 +9,9 @@ import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/Pro import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent'; import AgentCreateContent from './components/AgentCreateContent'; import AgentDebugPanel from './components/AgentDebugPanel'; -import AgentFormComponent from './components/AgentFormComponent'; +import AgentFormComponent, { + AgentRunnerStatus, +} from './components/AgentFormComponent'; export default function AgentDetailContent({ id }: { id: string }) { const isCreateMode = id === 'new'; @@ -25,6 +27,9 @@ export default function AgentDetailContent({ id }: { id: string }) { const [loading, setLoading] = useState(!isCreateMode); const [formDirty, setFormDirty] = useState(false); const [formSaving, setFormSaving] = useState(false); + const [runnerStatus, setRunnerStatus] = useState( + null, + ); useEffect(() => { if (isCreateMode) { @@ -37,6 +42,10 @@ export default function AgentDetailContent({ id }: { id: string }) { return () => setDetailEntityName(null); }, [id, isCreateMode, pipelines, setDetailEntityName, t]); + useEffect(() => { + setRunnerStatus(null); + }, [id]); + useEffect(() => { if (isCreateMode) return; let cancelled = false; @@ -81,6 +90,7 @@ export default function AgentDetailContent({ id }: { id: string }) { } diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index b0684da49..709df912f 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -1,23 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { - Bot, - CircleAlert, - CircleCheck, - Info, - LoaderCircle, - Power, - RefreshCw, - SlidersHorizontal, - Trash2, - Unplug, - Zap, -} from 'lucide-react'; +import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { @@ -54,7 +41,12 @@ import { FormLabel, FormMessage, } from '@/components/ui/form'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +export interface AgentRunnerStatus { + label: string; + description?: string; + tone: 'neutral' | 'success' | 'warning' | 'error'; +} interface AgentFormComponentProps { agentId: string; @@ -62,6 +54,7 @@ interface AgentFormComponentProps { onDeleted: () => void; onDirtyChange?: (dirty: boolean) => void; onSavingChange?: (saving: boolean) => void; + onRunnerStatusChange?: (status: AgentRunnerStatus) => void; } type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic'; @@ -72,6 +65,7 @@ export default function AgentFormComponent({ onDeleted, onDirtyChange, onSavingChange, + onRunnerStatusChange, }: AgentFormComponentProps) { const { t } = useTranslation(); const [runnerConfigSchema, setRunnerConfigSchema] = @@ -83,7 +77,7 @@ export default function AgentFormComponent({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); const [activeSection, setActiveSection] = - useState('runner'); + useState('basic'); const isSavingRef = useRef(false); const formSchema = z.object({ @@ -201,6 +195,11 @@ export default function AgentFormComponent({ label: string; icon: React.ElementType; }> = [ + { + name: 'basic', + label: t('agents.basicInfo'), + icon: Info, + }, { name: 'events', label: t('agents.bindableEvents'), @@ -220,116 +219,76 @@ export default function AgentFormComponent({ }, ]; - function renderRunnerStatusActions(showRetry = true) { - return ( -
- {showRetry && ( - - )} - -
- ); - } - - function renderRunnerStatus() { + const runnerStatus = useMemo(() => { if (pluginStatusLoading) { - return ( - - - {t('agents.runnerStatusLoading')} - - ); + return { + label: t('agents.runnerStatusLoading'), + tone: 'neutral', + }; } if (pluginStatusError || !pluginSystemStatus) { - return ( - - - {t('agents.runnerStatusCheckFailed')} - - {t('agents.runnerStatusCheckFailedDescription')} - {renderRunnerStatusActions()} - - - ); + return { + label: t('agents.runnerStatusCheckFailed'), + description: t('agents.runnerStatusCheckFailedDescription'), + tone: 'error', + }; } if (!pluginSystemStatus.is_enable) { - return ( - - - {t('plugins.systemDisabled')} - - {t('plugins.systemDisabledDesc')} - {renderRunnerStatusActions(false)} - - - ); + return { + label: t('plugins.systemDisabled'), + description: t('plugins.systemDisabledDesc'), + tone: 'error', + }; } if (!pluginSystemStatus.is_connected) { - return ( - - - {t('plugins.connectionError')} - - {t('plugins.connectionErrorDesc')} - {renderRunnerStatusActions()} - - - ); + return { + label: t('plugins.connectionError'), + description: t('plugins.connectionErrorDesc'), + tone: 'error', + }; } if (runnerOptions.length === 0) { - return ( - - - {t('agents.noRunnersAvailable')} - - {t('agents.noRunnersAvailableDescription')} - {renderRunnerStatusActions()} - - - ); + return { + label: t('agents.noRunnersAvailable'), + description: t('agents.noRunnersAvailableDescription'), + tone: 'error', + }; } if (!currentRunner || !selectedRunnerOption) { - return ( - - - {t('agents.selectedRunnerUnavailable')} - - {t('agents.selectedRunnerUnavailableDescription', { - runner: currentRunner || t('agents.noRunnerSelected'), - })} - {renderRunnerStatusActions()} - - - ); + return { + label: t('agents.selectedRunnerUnavailable'), + description: t('agents.selectedRunnerUnavailableDescription', { + runner: currentRunner || t('agents.noRunnerSelected'), + }), + tone: 'warning', + }; } - return ( - - - {t('agents.runnerReady')} - - {t('agents.runnerReadyDescription', { - runner: extractI18nObject(selectedRunnerOption.label), - })} - - - ); - } + return { + label: t('agents.runnerReady'), + description: t('agents.runnerReadyDescription', { + runner: extractI18nObject(selectedRunnerOption.label), + }), + tone: 'success', + }; + }, [ + currentRunner, + pluginStatusError, + pluginStatusLoading, + pluginSystemStatus, + runnerOptions.length, + selectedRunnerOption, + t, + ]); + + useEffect(() => { + onRunnerStatusChange?.(runnerStatus); + }, [onRunnerStatusChange, runnerStatus]); function updateSnapshotIfInitial(stageKey: string) { if (!initializedStagesRef.current.has(stageKey)) { @@ -472,7 +431,7 @@ export default function AgentFormComponent({ >
{activeSection === 'runner' && (
- {renderRunnerStatus()} {runnerSelectorStage ? renderDynamicStage(runnerSelectorStage) : !runnerConfigSchema && ( @@ -628,6 +574,7 @@ export default function AgentFormComponent({ diff --git a/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx b/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx index 674bc90d0..11e8cce5e 100644 --- a/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx +++ b/web/src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx @@ -1,6 +1,11 @@ import { ReactNode, useState } from 'react'; import { BarChart3, Bug, Settings } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; interface ProcessorMonitoringView { @@ -8,8 +13,15 @@ interface ProcessorMonitoringView { content: ReactNode; } +export interface ProcessorDetailStatus { + label: string; + description?: string; + tone: 'neutral' | 'success' | 'warning' | 'error'; +} + interface ProcessorDetailWorkbenchProps { title: string; + status?: ProcessorDetailStatus | null; saveLabel: string; saveFormId: string; canSave: boolean; @@ -28,6 +40,7 @@ interface ProcessorDetailWorkbenchProps { export default function ProcessorDetailWorkbench({ title, + status, saveLabel, saveFormId, canSave, @@ -51,7 +64,51 @@ export default function ProcessorDetailWorkbench({ return (
-

{title}

+
+

{title}

+ {status && ( + + + + + {status.label} + + + +

{status.label}

+ {status.description && ( +

+ {status.description} +

+ )} +
+
+ )} +
{monitoring && ( diff --git a/web/tests/e2e/processor-detail-workbench.spec.ts b/web/tests/e2e/processor-detail-workbench.spec.ts index b46423162..79b290bfe 100644 --- a/web/tests/e2e/processor-detail-workbench.spec.ts +++ b/web/tests/e2e/processor-detail-workbench.spec.ts @@ -35,16 +35,32 @@ test.describe('processor detail workbench', () => { const flow = configPanel.locator('ol'); await expect(flow.getByRole('button').nth(0)).toContainText( + 'Basic Information', + ); + await expect(flow.getByRole('button').nth(1)).toContainText( 'Bindable Event Range', ); - await expect(flow.getByRole('button').nth(1)).toContainText('Runner'); - await expect(flow.getByRole('button').nth(2)).toContainText('Local Agent'); + await expect(flow.getByRole('button').nth(2)).toContainText('Runner'); + await expect(flow.getByRole('button').nth(3)).toContainText('Local Agent'); - await flow.getByRole('button').nth(0).click(); + await expect(configPanel.getByLabel('Name')).toBeVisible(); + await expect(configPanel.getByLabel('Icon')).toBeVisible(); + await expect(configPanel.getByLabel('Description')).toBeVisible(); + + 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.', + ), + ).toBeVisible(); + + await flow.getByRole('button').nth(1).click(); await expect( configPanel.getByText('Bindable Event Range', { exact: true }).last(), ).toBeVisible(); - await flow.getByRole('button').nth(2).click(); + await flow.getByRole('button').nth(3).click(); await expect( configPanel.getByText('Local Agent', { exact: true }).last(), ).toBeVisible(); diff --git a/web/tests/unit/processor-detail-workbench.test.mjs b/web/tests/unit/processor-detail-workbench.test.mjs index f213d42db..e078391ae 100644 --- a/web/tests/unit/processor-detail-workbench.test.mjs +++ b/web/tests/unit/processor-detail-workbench.test.mjs @@ -59,12 +59,12 @@ test('processor forms expose their primary orchestration flow horizontally', () assert.match( agentForm, - /name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/, + /name: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/, ); assert.match( pipelineForm, /const primarySectionNames = \['trigger', 'ai', 'output'\]/, ); - assert.match(agentForm, /grid min-w-\[34rem\] grid-cols-3/); + assert.match(agentForm, /grid min-w-\[44rem\] grid-cols-4/); assert.match(pipelineForm, /grid min-w-\[34rem\] grid-cols-3/); });