diff --git a/app/web/src/i18n/index.ts b/app/web/src/i18n/index.ts
index 0397d461c..d315f201c 100644
--- a/app/web/src/i18n/index.ts
+++ b/app/web/src/i18n/index.ts
@@ -43,24 +43,12 @@ i18n.use(initReactI18next).init({
resources: {
zh: {
common: zhCommon,
-
-
[SHARED_WORKBENCH_I18N_NAMESPACE]: sharedWorkbenchResources.zh,
-
-
-
-
[CHATVIEW_I18N_NAMESPACE]: chatviewResources.zh,
},
en: {
common: enCommon,
-
-
[SHARED_WORKBENCH_I18N_NAMESPACE]: sharedWorkbenchResources.en,
-
-
-
-
[CHATVIEW_I18N_NAMESPACE]: chatviewResources.en,
},
},
diff --git a/app/workbench/src/WorkbenchRoutes.tsx b/app/workbench/src/WorkbenchRoutes.tsx
index e9f45ed96..f1344b338 100644
--- a/app/workbench/src/WorkbenchRoutes.tsx
+++ b/app/workbench/src/WorkbenchRoutes.tsx
@@ -1,14 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CHATVIEW_I18N_NAMESPACE } from '@shared/chatview/i18n/resources';
-import { WorkbenchAgentsRouteView } from './routes/WorkbenchAgentsRouteView';
-import { WorkbenchContactsRouteView } from './routes/WorkbenchContactsRouteView';
-import { WorkbenchDevicesRouteView } from './routes/WorkbenchDevicesRouteView';
-import { WorkbenchDocsRouteView } from './routes/WorkbenchDocsRouteView';
-import { WorkbenchProjectsRouteView } from './routes/WorkbenchProjectsRouteView';
-import { WorkbenchSettingsRouteView } from './routes/WorkbenchSettingsRouteView';
-import { WorkbenchTasksRouteView } from './routes/WorkbenchTasksRouteView';
-import { WorkbenchUsageRouteView } from './routes/WorkbenchUsageRouteView';
import { PageErrorBoundary } from './PageErrorBoundary';
import { useWorkbenchAgentsRoute } from './routes/useWorkbenchAgentsRoute';
import { useWorkbenchContactsRoute } from './routes/useWorkbenchContactsRoute';
@@ -23,7 +15,46 @@ import type { WorkbenchRoutesProps } from './routes/workbenchRoutesTypes';
import type { WorkbenchProfileSource } from './profileRegistry';
import styles from './AgentHubWorkbench.module.css';
-// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ──
+const WorkbenchAgentsRouteView = React.lazy(() =>
+ import('./routes/WorkbenchAgentsRouteView').then((mod) => ({
+ default: mod.WorkbenchAgentsRouteView,
+ })),
+);
+const WorkbenchContactsRouteView = React.lazy(() =>
+ import('./routes/WorkbenchContactsRouteView').then((mod) => ({
+ default: mod.WorkbenchContactsRouteView,
+ })),
+);
+const WorkbenchDevicesRouteView = React.lazy(() =>
+ import('./routes/WorkbenchDevicesRouteView').then((mod) => ({
+ default: mod.WorkbenchDevicesRouteView,
+ })),
+);
+const WorkbenchDocsRouteView = React.lazy(() =>
+ import('./routes/WorkbenchDocsRouteView').then((mod) => ({
+ default: mod.WorkbenchDocsRouteView,
+ })),
+);
+const WorkbenchProjectsRouteView = React.lazy(() =>
+ import('./routes/WorkbenchProjectsRouteView').then((mod) => ({
+ default: mod.WorkbenchProjectsRouteView,
+ })),
+);
+const WorkbenchSettingsRouteView = React.lazy(() =>
+ import('./routes/WorkbenchSettingsRouteView').then((mod) => ({
+ default: mod.WorkbenchSettingsRouteView,
+ })),
+);
+const WorkbenchTasksRouteView = React.lazy(() =>
+ import('./routes/WorkbenchTasksRouteView').then((mod) => ({
+ default: mod.WorkbenchTasksRouteView,
+ })),
+);
+const WorkbenchUsageRouteView = React.lazy(() =>
+ import('./routes/WorkbenchUsageRouteView').then((mod) => ({
+ default: mod.WorkbenchUsageRouteView,
+ })),
+);
export type {
WorkbenchAgentProfilesStatus,
@@ -34,33 +65,8 @@ export type { WorkbenchContactsActions, WorkbenchContactsData } from './routes/u
export type { WorkbenchDocumentsActions } from './routes/useWorkbenchDocsRoute';
export type { WorkbenchProjectsStatus } from './routes/useWorkbenchProjectsRoute';
-/* ═══════════════════════════════════════════════════════════════════════
- #21 性能:route hook 条件化(保留状态的最小方案)
-
- 问题:所有 6 个 route hook 曾在 WorkbenchRoutes 中无条件执行 —— 不管
- active page 是哪一页,agents/projects/contacts/docs/tasks/settings 的
- hook 每次渲染都跑。
-
- 约束排查:
- - hooks 规则禁条件调用;route hook 内 "inactive 提前 return" 会让同一实例
- 的 hook 数量在页面切换时变化(0 ↔ N),违反规则。
- - lazy mount(activePage === 'x' && )会改变 unmount 行为:
- useWorkbenchAgentsRoute 持有未保存的 agent 草稿(agentDrafts)、
- useWorkbenchTasksRoute 持有任务编辑草稿、useWorkbenchProjectsRoute 持有
- hub 项目缓存与分页游标 —— 切页即丢,属回归,不可用。
-
- 方案:把 runs/projects/docs 三个 route 的 hook 移入独立、常驻挂载的
- gate 组件(React.memo + active 标志):
- - hook 调用无条件(组件挂载期 hook 数量恒定),状态跨页面切换保留;
- - inactive 时只渲染 null(视图挂载语义与原来 switch 一致);
- - WorkbenchRoutes 因无关原因重渲染时,gate props(数据引用 + 原始值 +
- useMemo 的 profiles)稳定 → memo 跳过 → hook 不再空转;
- - 各 route 内部状态变化也只触发自身 gate 重渲染,不再带动其余 hook。
- agents/contacts/settings 保留在 WorkbenchRoutes:agents+contacts 输出是
- profileSources 的输入(agentsRoute.agentConfigs / contactsData.members),
- settings 输出 realDataMode 供 3 个 hook 复用;三者热路径均已 useMemo,
- 未 memo 化的残余仅为闭包工厂分配(µs 级)。
- ═══════════════════════════════════════════════════════════════════════ */
+// Route hooks stay mounted across page switches so in-progress drafts and cached
+// data survive navigation; the gates only skip rendering when inactive.
interface WorkbenchTasksRouteGateProps {
/** 当前是否为该 route 的 active page(rail 上为 'runs')。 */
@@ -280,7 +286,7 @@ export function WorkbenchRoutes({
activePage === 'settings';
return (
- <>
+
{activePage === 'contacts' && (
)}
@@ -369,6 +375,6 @@ export function WorkbenchRoutes({
{t('routes.unconfigured', { page: activePage })}
)}
- >
+
);
}
diff --git a/app/workbench/src/__tests__/composer.test.tsx b/app/workbench/src/__tests__/composer.test.tsx
index 6286c7b03..a84d1e5ae 100644
--- a/app/workbench/src/__tests__/composer.test.tsx
+++ b/app/workbench/src/__tests__/composer.test.tsx
@@ -4,7 +4,7 @@
// Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench
// test shards. Must stay the first import so mock factories register before
// the component tree (and its virtua/@lobehub/icons deps) is evaluated.
-import { installWorkbenchTestHooks } from './helpers';
+import { installWorkbenchTestHooks, openRailPage } from './helpers';
import {
fireEvent,
@@ -378,7 +378,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '设置' }));
+ await openRailPage('设置');
fireEvent.click(screen.getByRole('button', { name: '本地开发' }));
fireEvent.click(screen.getByRole('button', { name: 'Ctrl+Enter 发送' }));
expect(window.localStorage.getItem('agenthub.workbench.composerSubmitBehavior')).toBe('ctrl-enter-send');
diff --git a/app/workbench/src/__tests__/helpers.tsx b/app/workbench/src/__tests__/helpers.tsx
index df545a0d6..732a31466 100644
--- a/app/workbench/src/__tests__/helpers.tsx
+++ b/app/workbench/src/__tests__/helpers.tsx
@@ -7,7 +7,7 @@
* @lobehub/icons) are evaluated.
*/
import React from 'react';
-import { cleanup, fireEvent, screen, within } from '@testing-library/react';
+import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
import { afterEach, beforeAll, vi } from 'vitest';
// Workbench chrome + transcript copy resolve through the shared test
@@ -16,6 +16,18 @@ import { useTestI18nLanguage } from '@shared/testing/i18n';
beforeAll(async () => {
await useTestI18nLanguage('zh');
+ // Route views are code-split; warm the module cache so the lazy chunk
+ // resolves inside the act() flush of openRailPage instead of a real I/O tick.
+ await Promise.all([
+ import('../routes/WorkbenchAgentsRouteView'),
+ import('../routes/WorkbenchContactsRouteView'),
+ import('../routes/WorkbenchDevicesRouteView'),
+ import('../routes/WorkbenchDocsRouteView'),
+ import('../routes/WorkbenchProjectsRouteView'),
+ import('../routes/WorkbenchSettingsRouteView'),
+ import('../routes/WorkbenchTasksRouteView'),
+ import('../routes/WorkbenchUsageRouteView'),
+ ]);
});
// jsdom has no layout engine, so virtua cannot measure the viewport/rows and
@@ -106,6 +118,16 @@ export function installWorkbenchTestHooks(): void {
});
}
+/**
+ * Navigates the rail to a page view. Route views are lazy chunks, so the click
+ * is flushed through act() and assertions can stay synchronous afterwards.
+ */
+export async function openRailPage(name: string): Promise {
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name }));
+ });
+}
+
/** inspector tabs beyond overview open on demand via + menu. */
export function restoreInspectorTab(mode: 'files' | 'browser'): void {
fireEvent.click(screen.getByRole('button', { name: '新建右侧窗口' }));
diff --git a/app/workbench/src/__tests__/hubPages.test.tsx b/app/workbench/src/__tests__/hubPages.test.tsx
index a20263074..36122d3e6 100644
--- a/app/workbench/src/__tests__/hubPages.test.tsx
+++ b/app/workbench/src/__tests__/hubPages.test.tsx
@@ -4,7 +4,7 @@
// Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench
// test shards. Must stay the first import so mock factories register before
// the component tree (and its virtua/@lobehub/icons deps) is evaluated.
-import { installWorkbenchTestHooks } from './helpers';
+import { installWorkbenchTestHooks, openRailPage } from './helpers';
import {
fireEvent,
@@ -55,7 +55,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main');
expect(projectMain).not.toBeNull();
const projectScope = within(projectMain as HTMLElement);
@@ -105,7 +105,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectMain = screen.getByRole('heading', { name: '暂无项目' }).closest('main');
expect(projectMain).not.toBeNull();
const projectScope = within(projectMain as HTMLElement);
@@ -123,7 +123,7 @@ describe('AgentHubWorkbench', () => {
});
});
- it('hides Hub Projects create affordances when project creation is unavailable', () => {
+ it('hides Hub Projects create affordances when project creation is unavailable', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -140,7 +140,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
expect(screen.getByRole('heading', { name: '暂无项目' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument();
@@ -148,7 +148,7 @@ describe('AgentHubWorkbench', () => {
expect(screen.queryByRole('button', { name: '创建项目' })).not.toBeInTheDocument();
});
- it('hides Hub Projects update affordances when project updates are unavailable', () => {
+ it('hides Hub Projects update affordances when project updates are unavailable', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -176,7 +176,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main');
expect(projectMain).not.toBeNull();
@@ -186,7 +186,7 @@ describe('AgentHubWorkbench', () => {
expect(projectScope.queryByRole('button', { name: '保存项目' })).not.toBeInTheDocument();
});
- it('reports selected Hub project ids to the Web adapter', () => {
+ it('reports selected Hub project ids to the Web adapter', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -231,7 +231,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
fireEvent.click(screen.getByText('Hub 项目二'));
expect(handleActiveProjectChange).toHaveBeenCalledWith('hub-project-2');
@@ -278,7 +278,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main');
expect(projectMain).not.toBeNull();
const projectScope = within(projectMain as HTMLElement);
@@ -327,7 +327,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main');
expect(projectMain).not.toBeNull();
const projectScope = within(projectMain as HTMLElement);
@@ -347,7 +347,7 @@ describe('AgentHubWorkbench', () => {
expect(projectScope.getByLabelText('项目名称')).toHaveValue('失败项目');
});
- it('renders supplied Hub AgentProfiles on the Agents rail page instead of mock agents', () => {
+ it('renders supplied Hub AgentProfiles on the Agents rail page instead of mock agents', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -376,7 +376,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!;
expect(within(page).getAllByText('Hub Architect').length).toBeGreaterThan(0);
@@ -407,7 +407,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!;
const emptyState = within(page).getByRole('region', { name: '暂无已安装 Agent' });
@@ -427,7 +427,7 @@ describe('AgentHubWorkbench', () => {
});
});
- it('installs a marketplace fixture into the runnable Agents page', () => {
+ it('installs a marketplace fixture into the runnable Agents page', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -442,7 +442,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
fireEvent.click(screen.getByRole('button', { name: 'Agent 市场' }));
fireEvent.click(screen.getAllByRole('button', { name: '安装' })[0]!);
@@ -454,7 +454,7 @@ describe('AgentHubWorkbench', () => {
expect(within(page).getByText('记忆未启用')).toBeInTheDocument();
});
- it('does not render mock Agents, Projects, or Tasks when approved-real data is missing', () => {
+ it('does not render mock Agents, Projects, or Tasks when approved-real data is missing', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -472,7 +472,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
const agentsPage = screen.getByRole('region', { name: '工作台页面' });
const agentsAlert = within(agentsPage).getByRole('alert', { name: 'Agent 加载失败' });
expect(agentsAlert).toHaveTextContent('Hub AgentProfiles unavailable');
@@ -480,13 +480,13 @@ describe('AgentHubWorkbench', () => {
expect(within(agentsPage).queryByText('Browser QA')).not.toBeInTheDocument();
expect(within(agentsPage).queryByText('DeepSeek-V4-Pro')).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
const projectsPage = screen.getByRole('region', { name: '工作台页面' });
expect(within(projectsPage).getByRole('alert')).toHaveTextContent('Hub Projects unavailable');
expect(within(projectsPage).getByRole('heading', { name: '暂无项目' })).toBeInTheDocument();
expect(within(projectsPage).queryByRole('heading', { name: 'AI 游戏项目' })).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: '任务' }));
+ await openRailPage('任务');
const tasksPage = screen.getByRole('region', { name: '工作台页面' });
// Real mode without a task backend shows the honest coming-soon empty
// state instead of mock task rows (#1818).
@@ -526,7 +526,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!;
fireEvent.change(within(page).getByLabelText('名称'), {
@@ -543,7 +543,7 @@ describe('AgentHubWorkbench', () => {
await waitFor(() => expect(onAgentDelete).toHaveBeenCalledWith('hub-agent-architect'));
});
- it('renders supplied Hub contacts on the Contacts rail page', () => {
+ it('renders supplied Hub contacts on the Contacts rail page', async () => {
const platform = createMockPlatform({
surface: 'web',
capabilities: { browserPreview: true },
@@ -572,7 +572,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '联系人' }));
+ await openRailPage('联系人');
const contactsPage = screen.getByRole('heading', { name: '组织内联系人' }).closest('main') || document.body;
expect(within(contactsPage).getByText('Hub 联系人')).toBeInTheDocument();
@@ -589,7 +589,7 @@ describe('AgentHubWorkbench', () => {
expect(within(servicePage).queryByText('账号与权限')).not.toBeInTheDocument();
});
- it('keeps the Tasks rail page interactive without leaving the v4 table shell', () => {
+ it('keeps the Tasks rail page interactive without leaving the v4 table shell', async () => {
const platform = createMockPlatform({
surface: 'desktop',
capabilities: { browserPreview: true },
@@ -606,7 +606,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '任务' }));
+ await openRailPage('任务');
const page = screen.getByRole('region', { name: '工作台页面' });
expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'runs');
diff --git a/app/workbench/src/__tests__/pages.test.tsx b/app/workbench/src/__tests__/pages.test.tsx
index 236991de4..390d77b40 100644
--- a/app/workbench/src/__tests__/pages.test.tsx
+++ b/app/workbench/src/__tests__/pages.test.tsx
@@ -3,7 +3,7 @@
// Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench
// test shards. Must stay the first import so mock factories register before
// the component tree (and its virtua/@lobehub/icons deps) is evaluated.
-import { installWorkbenchTestHooks } from './helpers';
+import { installWorkbenchTestHooks, openRailPage } from './helpers';
import {
fireEvent,
@@ -25,7 +25,7 @@ installWorkbenchTestHooks();
describe('AgentHubWorkbench', () => {
- it('routes global rail pages into the design workbench mode', () => {
+ it('routes global rail pages into the design workbench mode', async () => {
const platform = createMockPlatform({
surface: 'desktop',
capabilities: { browserPreview: true },
@@ -41,7 +41,7 @@ describe('AgentHubWorkbench', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: '联系人' }));
+ await openRailPage('联系人');
expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'contacts');
expect(screen.getByRole('main', { name: '工作区' })).toHaveAttribute('data-mode', 'workbench');
@@ -51,14 +51,14 @@ describe('AgentHubWorkbench', () => {
expect(screen.queryByPlaceholderText('发消息给 Builder')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: '组织内联系人' })).toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: 'Agent' }));
+ await openRailPage('Agent');
expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'agents');
expect(screen.getAllByText('Builder').length).toBeGreaterThan(0);
expect(screen.getAllByText('glm-5.1').length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: '@Agent' })).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole('button', { name: '项目' }));
+ await openRailPage('项目');
expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'projects');
expect(screen.getByRole('heading', { name: 'AI 游戏项目' })).toBeInTheDocument();
@@ -69,7 +69,7 @@ describe('AgentHubWorkbench', () => {
// Verify the project page renders correctly with its heading.
});
- it('opens the account profile popover from the global rail', () => {
+ it('opens the account profile popover from the global rail', async () => {
const platform = createMockPlatform({
surface: 'desktop',
capabilities: { browserPreview: true },
@@ -102,7 +102,7 @@ describe('AgentHubWorkbench', () => {
expect(screen.queryByRole('dialog', { name: '用户 账号菜单' })).not.toBeInTheDocument();
});
- it('opens a human contact profile instead of an Agent config error for friend avatars', () => {
+ it('opens a human contact profile instead of an Agent config error for friend avatars', async () => {
const platform = createMockPlatform({
surface: 'desktop',
capabilities: { browserPreview: true },
@@ -181,7 +181,7 @@ describe('AgentHubWorkbench', () => {
});
});
- it('roves the global rail page buttons with arrow keys (single tab stop)', () => {
+ it('roves the global rail page buttons with arrow keys (single tab stop)', async () => {
const platform = createMockPlatform({
surface: 'desktop',
capabilities: { browserPreview: false },