fix(web): unify processor creation and entity detail loading states

This commit is contained in:
Hyu
2026-09-15 13:51:39 +08:00
parent d497defbf7
commit 5c98329170
32 changed files with 729 additions and 154 deletions
+11 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -43,6 +44,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData(); const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]); const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [loading, setLoading] = useState(!isCreateMode); const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
@@ -77,6 +80,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (isCreateMode) return; if (isCreateMode) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setLoadFailed(false);
Promise.all([ Promise.all([
httpClient.getAgent(id), httpClient.getAgent(id),
httpClient.getAdapters().catch(() => ({ adapters: [] })), httpClient.getAdapters().catch(() => ({ adapters: [] })),
@@ -97,13 +101,16 @@ export default function AgentDetailContent({ id }: { id: string }) {
); );
setAgent(resp.agent); setAgent(resp.agent);
}) })
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id, isCreateMode]); }, [id, isCreateMode, loadAttempt]);
if (isCreateMode) { if (isCreateMode) {
return ( return (
@@ -116,13 +123,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
); );
} }
if (loading || !agent) { if (loadFailed)
return ( return (
<div className="flex h-full items-center justify-center text-muted-foreground"> <EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
{t('common.loading')}
</div>
); );
} if (loading || !agent) return <EntityLoadState />;
if (agent.kind === 'pipeline') { if (agent.kind === 'pipeline') {
return <PipelineDetailContent id={id} routeBase="/home/agents" />; return <PipelineDetailContent id={id} routeBase="/home/agents" />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
@@ -79,6 +80,7 @@ export default function PluginProcessorDetailContent({
const [events, setEvents] = useState<ProcessorRunEvent[]>([]); const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null); const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false); const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false); const [pagingEvents, setPagingEvents] = useState(false);
@@ -89,6 +91,7 @@ export default function PluginProcessorDetailContent({
const available = Boolean(component); const available = Boolean(component);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true);
setFailed(false); setFailed(false);
try { try {
const [metadata, page] = await Promise.all([ const [metadata, page] = await Promise.all([
@@ -99,6 +102,7 @@ export default function PluginProcessorDetailContent({
setPlatformTools(metadata.platform_tools ?? []); setPlatformTools(metadata.platform_tools ?? []);
setRuns(page.items); setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null); setCursor(page.has_more ? page.next_cursor : null);
setInitialLoadComplete(true);
} catch { } catch {
setFailed(true); setFailed(true);
} finally { } finally {
@@ -385,6 +389,9 @@ export default function PluginProcessorDetailContent({
</div> </div>
); );
if (!initialLoadComplete)
return <EntityLoadState error={failed} onRetry={() => void load()} />;
return ( return (
<ProcessorDetailWorkbench <ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`} title={`${agent.emoji || '🧩'} ${agent.name}`}
@@ -121,11 +121,7 @@ export default function AgentCreateContent({
form="agent-create-form" form="agent-create-form"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
> >
{t( {t('common.submit')}
kind === 'event_processor'
? 'agents.eventProcessor.create'
: 'common.submit',
)}
</Button> </Button>
</div> </div>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { import {
forwardRef, forwardRef,
type ForwardedRef, type ForwardedRef,
@@ -145,6 +146,8 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null); useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false); const [pluginStatusError, setPluginStatusError] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false); const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false); const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
@@ -208,6 +211,8 @@ function AgentFormComponent(
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setInitialDataLoaded(false);
setLoadFailed(false);
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)]) Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
.then(([metadata, resp]) => { .then(([metadata, resp]) => {
if (cancelled) return; if (cancelled) return;
@@ -274,12 +279,14 @@ function AgentFormComponent(
setInitialDataLoaded(true); setInitialDataLoaded(true);
}) })
.catch((err) => { .catch((err) => {
if (cancelled) return;
setLoadFailed(true);
toast.error(t('agents.loadError') + err.msg); toast.error(t('agents.loadError') + err.msg);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentId, form, t]); }, [agentId, form, t, loadAttempt]);
useEffect(() => { useEffect(() => {
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) { if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
@@ -390,7 +397,8 @@ function AgentFormComponent(
]; ];
const runnerStatus = useMemo<RunnerStatus>(() => { const runnerStatus = useMemo<RunnerStatus>(() => {
if (pluginStatusLoading) { if (loadFailed) return { label: t('common.loadFailed'), tone: 'error' };
if (!initialDataLoaded || pluginStatusLoading) {
return { return {
label: t('agents.runnerStatusLoading'), label: t('agents.runnerStatusLoading'),
tone: 'neutral', tone: 'neutral',
@@ -459,6 +467,8 @@ function AgentFormComponent(
tone: 'success', tone: 'success',
}; };
}, [ }, [
initialDataLoaded,
loadFailed,
currentRunner, currentRunner,
pluginStatusError, pluginStatusError,
pluginStatusLoading, pluginStatusLoading,
@@ -635,6 +645,7 @@ function AgentFormComponent(
} }
}, },
async save() { async save() {
if (!initialDataLoaded || loadFailed) return false;
if (!hasUnsavedChangesRef.current) return true; if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false; if (isSavingRef.current) return false;
const valid = await form.trigger(); const valid = await form.trigger();
@@ -642,9 +653,15 @@ function AgentFormComponent(
return (await saveValues(form.getValues())) ?? false; return (await saveValues(form.getValues())) ?? false;
}, },
}), }),
[form, saveValues], [form, initialDataLoaded, loadFailed, saveValues],
); );
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return ( return (
<div className="h-full p-0 flex flex-col"> <div className="h-full p-0 flex flex-col">
<Form {...form}> <Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function AgentsPage() {
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
if (detailId) { if (detailId) {
return <AgentDetailContent id={detailId} />; return <AgentDetailContent key={detailId} id={detailId} />;
} }
return ( return (
+19 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@@ -59,6 +60,8 @@ export default function BotDetailContent({ id }: { id: string }) {
const [adapterLabel, setAdapterLabel] = useState(''); const [adapterLabel, setAdapterLabel] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false); const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [bot, setBot] = useState<Bot | null>(null); const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false); const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null); const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
@@ -74,13 +77,17 @@ export default function BotDetailContent({ id }: { id: string }) {
// Fetch bot enable state // Fetch bot enable state
useEffect(() => { useEffect(() => {
if (!isCreateMode) { if (!isCreateMode) {
httpClient.getBot(id).then((res) => { setLoadFailed(false);
setBot(res.bot); httpClient
setBotEnabled(res.bot.enable ?? true); .getBot(id)
setEnableLoaded(true); .then((res) => {
}); setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
})
.catch(() => setLoadFailed(true));
} }
}, [id, isCreateMode]); }, [id, isCreateMode, loadAttempt]);
const handleEnableToggle = useCallback( const handleEnableToggle = useCallback(
async (checked: boolean) => { async (checked: boolean) => {
@@ -178,6 +185,12 @@ export default function BotDetailContent({ id }: { id: string }) {
); );
} }
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!enableLoaded) return <EntityLoadState />;
// ==================== Edit Mode ==================== // ==================== Edit Mode ====================
return ( return (
<> <>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { showBotError } from '../../bot-error'; import { showBotError } from '../../bot-error';
import React, { import React, {
forwardRef, forwardRef,
@@ -137,6 +138,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
// Track whether initial data loading is complete. // Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty. // setValue calls during init should NOT mark the form as dirty.
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const isInitializing = useRef(true); const isInitializing = useRef(true);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] = const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
@@ -225,49 +229,55 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, [initBotId, loadAttempt]);
function setBotFormValues() { function setBotFormValues() {
setInitialDataLoaded(false);
setLoadFailed(false);
isInitializing.current = true; isInitializing.current = true;
initBotFormComponent().then(() => { initBotFormComponent()
if (initBotId) { .then(() => {
getBotConfig(initBotId) if (initBotId) {
.then((val) => { return getBotConfig(initBotId)
// Use form.reset() to set values AND update the dirty baseline, .then((val) => {
// so isDirty stays false after initial load. // Use form.reset() to set values AND update the dirty baseline,
form.reset({ // so isDirty stays false after initial load.
name: val.name, form.reset({
description: val.description, name: val.name,
adapter: val.adapter, description: val.description,
adapter_config: val.adapter_config, adapter: val.adapter,
enable: val.enable, adapter_config: val.adapter_config,
event_bindings: val.event_bindings || [], enable: val.enable,
plugin_processors: val.plugin_processors || [], event_bindings: val.event_bindings || [],
}); plugin_processors: val.plugin_processors || [],
handleAdapterSelect(val.adapter); });
handleAdapterSelect(val.adapter);
if (val.webhook_full_url) { if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url); setWebhookUrl(val.webhook_full_url);
} else { } else {
setWebhookUrl(''); setWebhookUrl('');
} }
setExtraWebhookUrl(val.extra_webhook_full_url || ''); setExtraWebhookUrl(val.extra_webhook_full_url || '');
}) })
.catch((err) => { .catch((err) => {
toast.error( setLoadFailed(true);
t('bots.getBotConfigError') + (err as CustomApiError).msg, toast.error(
); t('bots.getBotConfigError') + (err as CustomApiError).msg,
}) );
.finally(() => { })
isInitializing.current = false; .finally(() => {
}); isInitializing.current = false;
} else { });
form.reset(); } else {
setWebhookUrl(''); form.reset();
setExtraWebhookUrl(''); setWebhookUrl('');
isInitializing.current = false; setExtraWebhookUrl('');
} isInitializing.current = false;
}); }
})
.catch(() => setLoadFailed(true))
.finally(() => setInitialDataLoaded(true));
} }
async function initBotFormComponent() { async function initBotFormComponent() {
@@ -460,6 +470,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
} }
} }
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return ( return (
<Form {...form}> <Form {...form}>
<form <form
+1 -1
View File
@@ -8,7 +8,7 @@ export default function BotConfigPage() {
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
if (detailId) { if (detailId) {
return <BotDetailContent id={detailId} />; return <BotDetailContent key={detailId} id={detailId} />;
} }
return ( return (
+12 -1
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@@ -57,16 +58,20 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata'); const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false); const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null); const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0); const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback( const loadKbInfo = useCallback(
async (kbId: string) => { async (kbId: string) => {
setLoadFailed(false);
try { try {
const resp = await httpClient.getKnowledgeBase(kbId); const resp = await httpClient.getKnowledgeBase(kbId);
setKbInfo(resp.base); setKbInfo(resp.base);
} catch (e) { } catch (e) {
setLoadFailed(true);
console.error('Failed to load KB info:', e); console.error('Failed to load KB info:', e);
toast.error( toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg, t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
@@ -81,7 +86,7 @@ export default function KBDetailContent({ id }: { id: string }) {
if (!isCreateMode) { if (!isCreateMode) {
loadKbInfo(id); loadKbInfo(id);
} }
}, [id, isCreateMode, loadKbInfo]); }, [id, isCreateMode, loadKbInfo, loadAttempt]);
const hasDocumentCapability = (): boolean => { const hasDocumentCapability = (): boolean => {
if (!kbInfo || !kbInfo.knowledge_engine) return false; if (!kbInfo || !kbInfo.knowledge_engine) return false;
@@ -179,6 +184,12 @@ export default function KBDetailContent({ id }: { id: string }) {
); );
} }
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!kbInfo) return <EntityLoadState />;
// ==================== Edit Mode ==================== // ==================== Edit Mode ====================
return ( return (
<> <>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -94,6 +95,9 @@ export default function KBForm({
Record<string, unknown> Record<string, unknown>
>({}); >({});
const [isEditing, setIsEditing] = useState(Boolean(initKbId)); const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison // Dirty tracking: snapshot of saved state for comparison
@@ -144,12 +148,15 @@ export default function KBForm({
}; };
useEffect(() => { useEffect(() => {
loadRagEngines().then(() => { setInitialDataLoaded(false);
if (initKbId) { setLoadFailed(false);
loadKbConfig(initKbId); loadRagEngines()
} .then(() => {
}); if (initKbId) return loadKbConfig(initKbId);
}, []); })
.catch(() => setLoadFailed(true))
.finally(() => setInitialDataLoaded(true));
}, [initKbId, loadAttempt]);
// Auto-select first engine when engines are loaded and no selection // Auto-select first engine when engines are loaded and no selection
useEffect(() => { useEffect(() => {
@@ -178,7 +185,7 @@ export default function KBForm({
const resp = await httpClient.getKnowledgeEngines(); const resp = await httpClient.getKnowledgeEngines();
setRagEngines(resp.engines); setRagEngines(resp.engines);
} catch (err) { } catch (err) {
console.error('Failed to load Knowledge Engines:', err); throw err;
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -211,8 +218,8 @@ export default function KBForm({
isInitializing.current = false; isInitializing.current = false;
}, 500); }, 500);
} catch (err) { } catch (err) {
console.error('Failed to load KB config:', err);
isInitializing.current = false; isInitializing.current = false;
throw err;
} }
}; };
@@ -321,6 +328,12 @@ export default function KBForm({
[selectedEngine?.retrieval_schema], [selectedEngine?.retrieval_schema],
); );
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return ( return (
<Form {...form}> <Form {...form}>
<form <form
+1 -1
View File
@@ -48,7 +48,7 @@ export default function KnowledgePage() {
externalKbCount={migrationExternalCount} externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete} onMigrationComplete={handleMigrationComplete}
/> />
<KBDetailContent id={detailId} /> <KBDetailContent key={detailId} id={detailId} />
</> </>
); );
} }
+20 -7
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -74,6 +75,8 @@ export default function MCPDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works // Enable state managed here so the header switch works
const [serverEnabled, setServerEnabled] = useState(true); const [serverEnabled, setServerEnabled] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [enableLoaded, setEnableLoaded] = useState(false); const [enableLoaded, setEnableLoaded] = useState(false);
const [detailRuntimeStatus, setDetailRuntimeStatus] = const [detailRuntimeStatus, setDetailRuntimeStatus] =
useState<MCPRuntimeState | null>(null); useState<MCPRuntimeState | null>(null);
@@ -120,14 +123,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
useEffect(() => { useEffect(() => {
if (!isCreateMode) { if (!isCreateMode) {
setDetailRuntimeStatus(null); setDetailRuntimeStatus(null);
httpClient.getMCPServer(id).then((res) => { setLoadFailed(false);
const server = res.server ?? res; httpClient
setServerEnabled(server.enable ?? true); .getMCPServer(id)
setDetailRuntimeStatus(server.runtime_info?.status ?? null); .then((res) => {
setEnableLoaded(true); const server = res.server ?? res;
}); setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
})
.catch(() => setLoadFailed(true));
} }
}, [id, isCreateMode]); }, [id, isCreateMode, loadAttempt]);
const handleEnableToggle = useCallback( const handleEnableToggle = useCallback(
async (checked: boolean) => { async (checked: boolean) => {
@@ -325,6 +332,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
); );
// ==================== Edit Mode ==================== // ==================== Edit Mode ====================
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!enableLoaded) return <EntityLoadState />;
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import React, { import React, {
type ReactNode, type ReactNode,
useState, useState,
@@ -563,6 +564,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode'); const watchMode = form.watch('mode');
const { const {
loading: boxLoading,
available: boxAvailable, available: boxAvailable,
hint: boxHint, hint: boxHint,
reason: boxReason, reason: boxReason,
@@ -577,6 +579,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable; watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox; const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(!isEditMode);
const { isDirty } = form.formState; const { isDirty } = form.formState;
useEffect(() => { useEffect(() => {
onDirtyChange?.(isDirty); onDirtyChange?.(isDirty);
@@ -606,10 +611,13 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
); );
useEffect(() => { useEffect(() => {
setLoadFailed(false);
setInitialDataLoaded(!isEditMode);
isInitializing.current = true; isInitializing.current = true;
if (isEditMode && initServerName) { if (isEditMode && initServerName) {
loadServerForEdit(initServerName).finally(() => { loadServerForEdit(initServerName).finally(() => {
isInitializing.current = false; isInitializing.current = false;
setInitialDataLoaded(true);
}); });
} else { } else {
form.reset({ form.reset({
@@ -636,7 +644,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
pollingIntervalRef.current = null; pollingIntervalRef.current = null;
} }
}; };
}, [initServerName]); }, [initServerName, loadAttempt]);
useEffect(() => { useEffect(() => {
if (!onDraftChange || isEditMode) return; if (!onDraftChange || isEditMode) return;
@@ -756,6 +764,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
setRuntimeInfo(server.runtime_info ?? null); setRuntimeInfo(server.runtime_info ?? null);
setReadme(server.readme ?? ''); setReadme(server.readme ?? '');
} catch (error) { } catch (error) {
setLoadFailed(true);
console.error('Failed to load server:', error); console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed')); toast.error(t('mcp.loadFailed'));
} }
@@ -1337,6 +1346,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
runtimePanel runtimePanel
); );
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded || boxLoading) return <EntityLoadState />;
if (layout === 'split') { if (layout === 'split') {
return ( return (
<Form {...form}> <Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function MCPPage() {
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
if (detailId) { if (detailId) {
return <MCPDetailContent id={detailId} />; return <MCPDetailContent key={detailId} id={detailId} />;
} }
return ( return (
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -52,6 +53,8 @@ export default function PipelineDetailContent({
const [formDirty, setFormDirty] = useState(false); const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false); const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null); const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
const pipelineFormRef = useRef<PipelineFormHandle>(null); const pipelineFormRef = useRef<PipelineFormHandle>(null);
const sidebarPipeline = pipelines.find((item) => item.id === id); const sidebarPipeline = pipelines.find((item) => item.id === id);
@@ -59,13 +62,17 @@ export default function PipelineDetailContent({
useEffect(() => { useEffect(() => {
if (isCreateMode) return; if (isCreateMode) return;
let cancelled = false; let cancelled = false;
httpClient.getPipeline(id).then((response) => { setLoadFailed(false);
if (!cancelled) setPipelineDetails(response.pipeline); httpClient
}); .getPipeline(id)
.then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
})
.catch(() => setLoadFailed(true));
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id, isCreateMode]); }, [id, isCreateMode, loadAttempt]);
function handleFinish() { function handleFinish() {
refreshPipelines(); refreshPipelines();
@@ -137,6 +144,12 @@ export default function PipelineDetailContent({
navigate(routeBase); navigate(routeBase);
} }
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pipelineDetails) return <EntityLoadState />;
// ==================== Edit Mode ==================== // ==================== Edit Mode ====================
const pipelineName = const pipelineName =
pipelineDetails?.name || pipelineDetails?.name ||
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -211,6 +212,8 @@ const PipelineFormComponent = forwardRef<
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [outputConfigTabSchema, setOutputConfigTabSchema] = const [outputConfigTabSchema, setOutputConfigTabSchema] =
useState<PipelineConfigTab>(); useState<PipelineConfigTab>();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [metadataLoaded, setMetadataLoaded] = useState(false); const [metadataLoaded, setMetadataLoaded] = useState(false);
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode); const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
@@ -257,28 +260,37 @@ const PipelineFormComponent = forwardRef<
}, [hasUnsavedChanges, onDirtyChange]); }, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setMetadataLoaded(false); setMetadataLoaded(false);
setPipelineLoaded(!isEditMode); setPipelineLoaded(!isEditMode);
// get config schema from metadata // get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => { httpClient
for (const config of resp.configs) { .getGeneralPipelineMetadata()
if (config.name === 'ai') { .then((resp) => {
setAIConfigTabSchema(config); if (cancelled) return;
} else if (config.name === 'trigger') { for (const config of resp.configs) {
setTriggerConfigTabSchema(config); if (config.name === 'ai') {
} else if (config.name === 'safety') { setAIConfigTabSchema(config);
setSafetyConfigTabSchema(config); } else if (config.name === 'trigger') {
} else if (config.name === 'output') { setTriggerConfigTabSchema(config);
setOutputConfigTabSchema(config); } else if (config.name === 'safety') {
setSafetyConfigTabSchema(config);
} else if (config.name === 'output') {
setOutputConfigTabSchema(config);
}
} }
} setMetadataLoaded(true);
setMetadataLoaded(true); })
}); .catch(() => {
if (!cancelled) setLoadFailed(true);
});
if (isEditMode) { if (isEditMode) {
httpClient httpClient
.getPipeline(pipelineId || '') .getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => { .then((resp: GetPipelineResponseData) => {
if (cancelled) return;
setIsDefaultPipeline(resp.pipeline.is_default ?? false); setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = { const loadedValues = {
@@ -296,9 +308,15 @@ const PipelineFormComponent = forwardRef<
savedSnapshotRef.current = JSON.stringify(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear(); initializedStagesRef.current.clear();
setPipelineLoaded(true); setPipelineLoaded(true);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
}); });
} }
}, [form, isEditMode, pipelineId]); return () => {
cancelled = true;
};
}, [form, isEditMode, pipelineId, loadAttempt]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -693,6 +711,12 @@ const PipelineFormComponent = forwardRef<
} }
}; };
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!metadataLoaded || !pipelineLoaded) return <EntityLoadState />;
return ( return (
<> <>
<div className="h-full p-0 flex flex-col"> <div className="h-full p-0 flex flex-col">
+1 -1
View File
@@ -8,7 +8,7 @@ export default function PipelineConfigPage() {
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
if (detailId) { if (detailId) {
return <PipelineDetailContent id={detailId} />; return <PipelineDetailContent key={detailId} id={detailId} />;
} }
return ( return (
+3 -8
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
@@ -78,11 +79,7 @@ export default function PluginPagesPage() {
</div> </div>
); );
} }
return ( return <EntityLoadState />;
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
} }
const assetPath = page.path; const assetPath = page.path;
@@ -209,9 +206,7 @@ function PluginPageIframe({
{t('plugins.loadFailed')} {t('plugins.loadFailed')}
</div> </div>
) : loading || !assetUrl ? ( ) : loading || !assetUrl ? (
<div className="flex items-center justify-center h-full text-muted-foreground"> <EntityLoadState />
Loading...
</div>
) : null} ) : null}
{!assetError && assetUrl && ( {!assetError && assetUrl && (
<iframe <iframe
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm'; import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
@@ -40,6 +41,8 @@ export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData(); const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null); const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteData, setDeleteData] = useState(false); const [deleteData, setDeleteData] = useState(false);
@@ -76,15 +79,19 @@ export default function PluginDetailContent({ id }: { id: string }) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => { setLoadFailed(false);
if (!cancelled) { httpClient
setPluginInfo(res.plugin); .getPlugin(pluginAuthor, pluginName)
} .then((res) => {
}); if (!cancelled) {
setPluginInfo(res.plugin);
}
})
.catch(() => setLoadFailed(true));
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [pluginAuthor, pluginName]); }, [pluginAuthor, pluginName, loadAttempt]);
function handleFormSubmit(timeout?: number) { function handleFormSubmit(timeout?: number) {
if (timeout) { if (timeout) {
@@ -189,6 +196,12 @@ export default function PluginDetailContent({ id }: { id: string }) {
</Card> </Card>
); );
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pluginInfo) return <EntityLoadState />;
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api'; import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin'; import { Plugin } from '@/app/infra/entities/plugin';
@@ -25,6 +26,8 @@ export default function PluginForm({
onFormSubmit: (timeout?: number) => void; onFormSubmit: (timeout?: number) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin>(); const [pluginInfo, setPluginInfo] = useState<Plugin>();
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>(); const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false); const [isSaving, setIsLoading] = useState(false);
@@ -33,36 +36,55 @@ export default function PluginForm({
const initialFileKeys = useRef<Set<string>>(new Set()); const initialFileKeys = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setPluginInfo(undefined);
setPluginConfig(undefined);
// 获取插件信息 // 获取插件信息
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => { httpClient
setPluginInfo(res.plugin); .getPlugin(pluginAuthor, pluginName)
}); .then((res) => {
if (cancelled) return;
setPluginInfo(res.plugin);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
// 获取插件配置 // 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => { httpClient
setPluginConfig(res); .getPluginConfig(pluginAuthor, pluginName)
.then((res) => {
if (cancelled) return;
setPluginConfig(res);
// 提取初始配置中的所有文件 key // 提取初始配置中的所有文件 key
const extractFileKeys = (obj: any): string[] => { const extractFileKeys = (obj: any): string[] => {
const keys: string[] = []; const keys: string[] = [];
if (obj && typeof obj === 'object') { if (obj && typeof obj === 'object') {
if ('file_key' in obj && typeof obj.file_key === 'string') { if ('file_key' in obj && typeof obj.file_key === 'string') {
keys.push(obj.file_key); keys.push(obj.file_key);
} }
for (const value of Object.values(obj)) { for (const value of Object.values(obj)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((item) => keys.push(...extractFileKeys(item))); value.forEach((item) => keys.push(...extractFileKeys(item)));
} else if (typeof value === 'object' && value !== null) { } else if (typeof value === 'object' && value !== null) {
keys.push(...extractFileKeys(value)); keys.push(...extractFileKeys(value));
}
} }
} }
} return keys;
return keys; };
};
const fileKeys = extractFileKeys(res.config); const fileKeys = extractFileKeys(res.config);
initialFileKeys.current = new Set(fileKeys); initialFileKeys.current = new Set(fileKeys);
}); })
}, [pluginAuthor, pluginName]); .catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName, loadAttempt]);
const handleSubmit = async () => { const handleSubmit = async () => {
setIsLoading(true); setIsLoading(true);
@@ -132,13 +154,11 @@ export default function PluginForm({
} }
}; };
if (!pluginInfo || !pluginConfig) { if (loadFailed)
return ( return (
<div className="flex items-center justify-center h-full mb-[2rem]"> <EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
{t('plugins.loading')}
</div>
); );
} if (!pluginInfo || !pluginConfig) return <EntityLoadState />;
return ( return (
<div className="min-w-0 max-w-full space-y-4"> <div className="min-w-0 max-w-full space-y-4">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -88,12 +89,15 @@ export default function PluginReadme({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [readme, setReadme] = useState<string>(''); const [readme, setReadme] = useState<string>('');
const [isLoadingReadme, setIsLoadingReadme] = useState(false); const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [isLoadingReadme, setIsLoadingReadme] = useState(true);
const language = getAPILanguageCode(); const language = getAPILanguageCode();
useEffect(() => { useEffect(() => {
// Fetch plugin README // Fetch plugin README
setLoadFailed(false);
setIsLoadingReadme(true); setIsLoadingReadme(true);
httpClient httpClient
.getPluginReadme(pluginAuthor, pluginName, language) .getPluginReadme(pluginAuthor, pluginName, language)
@@ -101,19 +105,22 @@ export default function PluginReadme({
setReadme(res.readme); setReadme(res.readme);
}) })
.catch(() => { .catch(() => {
setLoadFailed(true);
setReadme(''); setReadme('');
}) })
.finally(() => { .finally(() => {
setIsLoadingReadme(false); setIsLoadingReadme(false);
}); });
}, [pluginAuthor, pluginName]); }, [pluginAuthor, pluginName, language, loadAttempt]);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
return ( return (
<div className="w-full h-full overflow-auto overscroll-none"> <div className="w-full h-full overflow-auto overscroll-none">
{isLoadingReadme ? ( {isLoadingReadme ? (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400"> <EntityLoadState />
{t('plugins.loadingReadme')}
</div>
) : readme ? ( ) : readme ? (
<div className="markdown-body p-6 max-w-none pt-0"> <div className="markdown-body p-6 max-w-none pt-0">
<ReactMarkdown <ReactMarkdown
+1 -1
View File
@@ -36,7 +36,7 @@ export default function PluginConfigPage() {
const detailId = searchParams.get('id'); const detailId = searchParams.get('id');
if (detailId) { if (detailId) {
return <PluginDetailContent id={detailId} />; return <PluginDetailContent key={detailId} id={detailId} />;
} }
return <PluginListView />; return <PluginListView />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -37,6 +38,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id); const skill = skills.find((item) => item.id === id);
const { const {
loading: boxLoading,
available: boxAvailable, available: boxAvailable,
hint: boxHint, hint: boxHint,
reason: boxReason, reason: boxReason,
@@ -77,6 +79,8 @@ export default function SkillDetailContent({ id }: { id: string }) {
} }
} }
if (boxLoading) return <EntityLoadState />;
if (isCreateMode) { if (isCreateMode) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { import {
type FormEvent, type FormEvent,
type ReactNode, type ReactNode,
@@ -280,7 +281,8 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>( const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
new Map(), new Map(),
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [filesFailed, setFilesFailed] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -288,12 +290,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
}, [selectedFile]); }, [selectedFile]);
const loadRootFiles = useCallback(async () => { const loadRootFiles = useCallback(async () => {
setFilesFailed(false);
setLoading(true); setLoading(true);
onLoadingChange?.(true); onLoadingChange?.(true);
try { try {
const result = await httpClient.listSkillFiles(skillName, '.'); const result = await httpClient.listSkillFiles(skillName, '.');
setRootEntries(result.entries); setRootEntries(result.entries);
} catch (error) { } catch (error) {
setFilesFailed(true);
console.error('Failed to load skill files:', error); console.error('Failed to load skill files:', error);
toast.error(t('skills.loadFilesError') + String(error)); toast.error(t('skills.loadFilesError') + String(error));
} finally { } finally {
@@ -416,6 +420,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
); );
}; };
if (loading || filesFailed)
return (
<EntityLoadState
error={filesFailed}
onRetry={() => void loadRootFiles()}
/>
);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1"> <div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
@@ -592,18 +604,26 @@ export default function SkillForm({
const [fileContent, setFileContent] = useState<string>(''); const [fileContent, setFileContent] = useState<string>('');
const fileTreeRef = useRef<FileTreeHandle>(null); const fileTreeRef = useRef<FileTreeHandle>(null);
const directoryInputRef = useRef<HTMLInputElement>(null); const directoryInputRef = useRef<HTMLInputElement>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(!initSkillName);
const [fileTreeLoading, setFileTreeLoading] = useState(false); const [fileTreeLoading, setFileTreeLoading] = useState(false);
const loadSkill = useCallback( const loadSkill = useCallback(
async (skillName: string) => { async (skillName: string) => {
setInitialDataLoaded(false);
setLoadFailed(false);
try { try {
const resp = await httpClient.getSkill(skillName); const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill); setSkill(resp.skill);
setSelectedFile('SKILL.md'); setSelectedFile('SKILL.md');
setFileContent(resp.skill.instructions || ''); setFileContent(resp.skill.instructions || '');
} catch (error) { } catch (error) {
setLoadFailed(true);
console.error('Failed to load skill:', error); console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error)); toast.error(t('skills.getSkillListError') + String(error));
} finally {
setInitialDataLoaded(true);
} }
}, },
[t], [t],
@@ -627,7 +647,7 @@ export default function SkillForm({
setDirectorySourceName(''); setDirectorySourceName('');
setDirectoryTree([]); setDirectoryTree([]);
setDirectoryFileMap(new Map()); setDirectoryFileMap(new Map());
}, [initSkillName, loadSkill]); }, [initSkillName, loadSkill, loadAttempt]);
useEffect(() => { useEffect(() => {
if (initSkillName) return; if (initSkillName) return;
@@ -959,6 +979,12 @@ export default function SkillForm({
</div> </div>
); );
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
if (layout === 'split') { if (layout === 'split') {
return ( return (
<form <form
+1 -1
View File
@@ -34,7 +34,7 @@ export default function SkillsPage() {
}, [detailId, isCreateView, navigate]); }, [detailId, isCreateView, navigate]);
if (detailId) { if (detailId) {
return <SkillDetailContent id={detailId} />; return <SkillDetailContent key={detailId} id={detailId} />;
} }
function handleCreatedSkill(skillName: string) { function handleCreatedSkill(skillName: string) {
+36
View File
@@ -0,0 +1,36 @@
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
export default function EntityLoadState({
error = false,
onRetry,
}: {
error?: boolean;
onRetry?: () => void;
}) {
const { t } = useTranslation();
return (
<div
role={error ? 'alert' : 'status'}
aria-busy={!error}
className="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
>
{error ? (
<>
<p>{t('common.loadFailed')}</p>
{onRetry && (
<Button type="button" variant="outline" onClick={onRetry}>
{t('common.retry')}
</Button>
)}
</>
) : (
<>
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
<p>{t('common.loading')}</p>
</>
)}
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ const enUS = {
editionCloud: 'Cloud', editionCloud: 'Cloud',
}, },
common: { common: {
loadFailed: 'Failed to load. Please try again.',
login: 'Login', login: 'Login',
logout: 'Logout', logout: 'Logout',
accountOptions: 'Settings', accountOptions: 'Settings',
+1
View File
@@ -14,6 +14,7 @@ const jaJP = {
editionCloud: 'Cloud', editionCloud: 'Cloud',
}, },
common: { common: {
loadFailed: '読み込みに失敗しました。再試行してください。',
login: 'ログイン', login: 'ログイン',
logout: 'ログアウト', logout: 'ログアウト',
accountOptions: 'システム設定', accountOptions: 'システム設定',
+1
View File
@@ -14,6 +14,7 @@ const zhHans = {
editionCloud: 'Cloud', editionCloud: 'Cloud',
}, },
common: { common: {
loadFailed: '加载失败,请重试。',
login: '登录', login: '登录',
logout: '退出登录', logout: '退出登录',
accountOptions: '系统设置', accountOptions: '系统设置',
+2
View File
@@ -1012,6 +1012,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 12, install_count: 12,
latest_version: '1.0.0', latest_version: '1.0.0',
components: { Runner: 1 }, components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live', status: 'live',
type: 'plugin', type: 'plugin',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
@@ -1128,6 +1129,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 9, install_count: 9,
latest_version: '1.0.0', latest_version: '1.0.0',
components: { Runner: 1 }, components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live', status: 'live',
type: 'plugin', type: 'plugin',
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
+328
View File
@@ -0,0 +1,328 @@
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
async function setup(page: Page) {
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'loading-test',
session_type: 'person',
}),
);
});
});
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true,
});
await page.route('**/api/v1/plugins/qa/loading**', async (route) => {
const path = new URL(route.request().url()).pathname;
const data = path.endsWith('/config')
? { config: {} }
: path.endsWith('/readme')
? { readme: '# Loaded documentation' }
: {
plugin: {
manifest: {
manifest: {
metadata: {
author: 'qa',
name: 'loading',
label: { en_US: 'Loading test plugin' },
description: { en_US: 'Test' },
},
spec: { config: [] },
},
},
components: [],
},
};
await route.fulfill({ json: { code: 0, data } });
});
await page.route('**/api/v1/agents/processor-loading', (route) =>
route.fulfill({
json: {
code: 0,
data: {
agent: {
uuid: 'processor-loading',
kind: 'event_processor',
name: 'Loading test processor',
config: {},
supported_event_patterns: [],
},
},
},
}),
);
await page.route('**/api/v1/agents/processor-loading/runs**', (route) =>
route.fulfill({
json: {
code: 0,
data: { items: [], has_more: false, next_cursor: null },
},
}),
);
}
const cases = [
{
name: 'plugin documentation',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading/readme?**',
form: '[data-slot="card-title"]',
readyText: 'Loaded documentation',
},
{
name: 'bot adapters',
url: '/home/bots?id=bot-loading',
endpoint: '/platform/adapters',
form: '#bot-form',
},
{
name: 'processor run history',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/processor-loading/runs',
form: '#event-processor-form',
},
{
name: 'skill file list',
url: '/home/skills?id=skill-loading',
endpoint: '/skills/skill-loading/files?**',
form: '#skill-form',
readyText: 'SKILL.md',
},
{
name: 'bot details',
url: '/home/bots?id=bot-loading',
endpoint: '/platform/bots/bot-loading',
form: '#bot-form',
},
{
name: 'agent details',
url: '/home/agents?id=agent-loading',
endpoint: '/agents/agent-loading',
form: '#agent-form',
},
{
name: 'pipeline details',
url: '/home/agents?id=pipeline-loading',
endpoint: '/pipelines/pipeline-loading',
form: '#pipeline-form',
},
{
name: 'legacy pipeline route',
url: '/home/pipelines?id=pipeline-loading',
endpoint: '/pipelines/pipeline-loading',
form: '#pipeline-form',
},
{
name: 'plugin processor details',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/processor-loading',
form: '#event-processor-form',
},
{
name: 'knowledge base details',
url: '/home/knowledge?id=kb-loading',
endpoint: '/knowledge/bases/kb-loading',
form: '#kb-form',
},
{
name: 'MCP details',
url: '/home/mcp?id=mcp-loading',
endpoint: '/mcp/servers/mcp-loading',
form: '#mcp-form',
},
{
name: 'skill details',
url: '/home/skills?id=skill-loading',
endpoint: '/skills/skill-loading',
form: '#skill-form',
},
{
name: 'plugin details',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading',
form: '[data-slot="card-title"]',
},
{
name: 'agent metadata after runtime health',
url: '/home/agents?id=agent-loading',
endpoint: '/agents/_/metadata',
form: '#agent-form',
},
{
name: 'pipeline metadata',
url: '/home/agents?id=pipeline-loading',
endpoint: '/pipelines/_/metadata',
form: '#pipeline-form',
},
{
name: 'processor metadata',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/_/metadata',
form: '#event-processor-form',
},
{
name: 'knowledge engines',
url: '/home/knowledge?id=kb-loading',
endpoint: '/knowledge/engines',
form: '#kb-form',
},
{
name: 'plugin configuration',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading/config',
form: '[data-slot="card-title"]',
readyText: 'Plugin Configuration',
},
];
for (const scenario of cases) {
test(`${scenario.name}: loading until response arrives`, async ({ page }) => {
await setup(page);
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
requested = true;
await pending;
await route.fallback();
});
try {
await page.goto(scenario.url);
await expect.poll(() => requested).toBe(true);
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }).first(),
).toBeVisible();
await expect(
page.getByText('No runners are available', { exact: true }),
).toHaveCount(0);
if (scenario.name === 'agent metadata after runtime health') {
await page.screenshot({
path: '../../.codex-run/entity-loading-agent.png',
});
}
if (!scenario.readyText)
await expect(page.locator(scenario.form)).toHaveCount(0);
} finally {
release();
}
await expect(page.locator(scenario.form).first()).toBeAttached();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
test(`${scenario.name}: failed request can be retried`, async ({ page }) => {
await setup(page);
let fail = true;
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
if (fail)
await route.fulfill({
status: 503,
json: {
code: -1,
msg: 'Temporarily unavailable',
message: 'Temporarily unavailable',
},
});
else await route.fallback();
});
await page.goto(scenario.url);
const error = page
.getByRole('alert')
.filter({ hasText: 'Failed to load. Please try again.' });
await expect(error).toBeVisible();
fail = false;
await error.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(error).toHaveCount(0);
await expect(page.locator(scenario.form).first()).toBeAttached();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
}
test('completed empty metadata displays the genuine empty state', async ({
page,
}) => {
await setup(page);
await page.route('**/api/v1/agents/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: { runner_config: null, platform_tools: [], host_tools: [] },
},
}),
);
await page.goto('/home/agents?id=agent-empty');
await expect(
page.getByText('No runners are available', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
test('switching bots resets the form and ignores an old response', async ({
page,
}) => {
await setup(page);
await page.route('**/api/v1/platform/bots', (route) =>
route.fulfill({
json: {
code: 0,
data: {
bots: ['bot-first', 'bot-second'].map((uuid) => ({
uuid,
name: uuid,
adapter: 'aiocqhttp',
enable: true,
})),
},
},
}),
);
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
let responded = false;
await page.route('**/api/v1/platform/bots/bot-second', async (route) => {
requested = true;
await pending;
await route.fallback();
responded = true;
});
await page.goto('/home/bots?id=bot-first');
await expect(page.locator('#bot-form')).toBeVisible();
try {
await page.locator('a[href="/home/bots?id=bot-second"]').click();
await expect.poll(() => requested).toBe(true);
await expect(page.locator('#bot-form')).toHaveCount(0);
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toBeVisible();
await page.locator('a[href="/home/bots?id=bot-first"]').click();
await expect(page.locator('#bot-form')).toBeVisible();
} finally {
release();
}
await expect.poll(() => responded).toBe(true);
await expect(
page.getByRole('heading', { name: 'bot-first', exact: true }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'bot-second', exact: true }),
).toHaveCount(0);
});
+1 -3
View File
@@ -222,9 +222,7 @@ test('create first, select a plugin in the header, debug beside scrollable logs'
await page await page
.getByRole('textbox', { name: 'Name', exact: false }) .getByRole('textbox', { name: 'Name', exact: false })
.fill('Welcome processor'); .fill('Welcome processor');
await page await page.getByRole('button', { name: 'Submit', exact: true }).click();
.getByRole('button', { name: 'Create plugin processor', exact: true })
.click();
await expect(page).toHaveURL(/id=processor-qa/); await expect(page).toHaveURL(/id=processor-qa/);
expect(creations).toHaveLength(1); expect(creations).toHaveLength(1);
expect(creations[0]).toMatchObject({ expect(creations[0]).toMatchObject({