Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions app/web/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
82 changes: 44 additions & 38 deletions app/workbench/src/WorkbenchRoutes.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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' && <Route/>)会改变 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')。 */
Expand Down Expand Up @@ -280,7 +286,7 @@ export function WorkbenchRoutes({
activePage === 'settings';

return (
<>
<React.Suspense fallback={null}>
{activePage === 'contacts' && (
<WorkbenchContactsRouteView contactsRoute={contactsRoute} />
)}
Expand Down Expand Up @@ -369,6 +375,6 @@ export function WorkbenchRoutes({
{t('routes.unconfigured', { page: activePage })}
</div>
)}
</>
</React.Suspense>
);
}
4 changes: 2 additions & 2 deletions app/workbench/src/__tests__/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
24 changes: 23 additions & 1 deletion app/workbench/src/__tests__/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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: '新建右侧窗口' }));
Expand Down
Loading
Loading