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
5 changes: 5 additions & 0 deletions README.ja-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ AI を開発ワークフローに統合できます。エージェントを使
│ ├── i18n/ # ローカライズリソース
│ └── types/ # TypeScript 型定義
├── tests/
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
│ └── unit/ # Vitest ユニット/統合寄りテスト
├── resources/ # 静的アセット(アイコン、画像)
└── scripts/ # ビルド/ユーティリティスクリプト
Expand All @@ -335,6 +336,8 @@ pnpm typecheck # TypeScriptの型チェック

# テスト
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2E スモークテストを実行
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
Expand All @@ -348,6 +351,8 @@ pnpm package:win # Windows向けにパッケージ化
pnpm package:linux # Linux向けにパッケージ化
```

ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。

### 通信回帰チェック

PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
│ ├── i18n/ # Localization resources
│ └── types/ # TypeScript type definitions
├── tests/
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
│ └── unit/ # Vitest unit/integration-like tests
├── resources/ # Static assets (icons/images)
└── scripts/ # Build and utility scripts
Expand Down Expand Up @@ -354,6 +355,8 @@ pnpm package:win # Package for Windows
pnpm package:linux # Package for Linux
```

On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.

### Communication Regression Checks

When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
Expand Down
5 changes: 5 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
│ ├── i18n/ # 国际化资源
│ └── types/ # TypeScript 类型定义
├── tests/
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
│ └── unit/ # Vitest 单元/集成型测试
├── resources/ # 静态资源(图标、图片)
└── scripts/ # 构建与工具脚本
Expand All @@ -339,6 +340,8 @@ pnpm typecheck # TypeScript 类型检查

# 测试
pnpm test # 运行单元测试
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
Expand All @@ -352,6 +355,8 @@ pnpm package:win # 为 Windows 打包
pnpm package:linux # 为 Linux 打包
```

在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。

### 通信回归检查

当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
Expand Down
17 changes: 13 additions & 4 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';

const win = new BrowserWindow({
width: 1280,
Expand Down Expand Up @@ -195,12 +196,20 @@ function createWindow(): BrowserWindow {

// Load the app
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL);
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
if (shouldSkipSetupForE2E) {
rendererUrl.searchParams.set('e2eSkipSetup', '1');
}
win.loadURL(rendererUrl.toString());
if (!isE2EMode) {
win.webContents.openDevTools();
}
} else {
win.loadFile(join(__dirname, '../../dist/index.html'));
win.loadFile(join(__dirname, '../../dist/index.html'), {
query: shouldSkipSetupForE2E
? { e2eSkipSetup: '1' }
: undefined,
});
}

return win;
Expand Down Expand Up @@ -246,7 +255,7 @@ function createMainWindow(): BrowserWindow {
});

win.on('close', (event) => {
if (!isQuitting()) {
if (!isQuitting() && !isE2EMode) {
event.preventDefault();
win.hide();
}
Expand Down Expand Up @@ -546,7 +555,7 @@ if (gotTheLock) {
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
if (process.platform !== 'darwin' || isE2EMode) {
app.quit();
}
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,4 @@
"zx": "^8.8.5"
},
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
}
}
25 changes: 16 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ class ErrorBoundary extends Component<
function App() {
const navigate = useNavigate();
const location = useLocation();
const skipSetupForE2E = typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).get('e2eSkipSetup') === '1';
const initSettings = useSettingsStore((state) => state.init);
const theme = useSettingsStore((state) => state.theme);
const language = useSettingsStore((state) => state.language);
Expand Down Expand Up @@ -120,10 +122,10 @@ function App() {

// Redirect to setup wizard if not complete
useEffect(() => {
if (!setupComplete && !location.pathname.startsWith('/setup')) {
if (!setupComplete && !skipSetupForE2E && !location.pathname.startsWith('/setup')) {
navigate('/setup');
}
}, [setupComplete, location.pathname, navigate]);
}, [setupComplete, skipSetupForE2E, location.pathname, navigate]);

// Listen for navigation events from main process
useEffect(() => {
Expand Down
29 changes: 21 additions & 8 deletions src/pages/Agents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function Agents() {
deleteAgent,
} = useAgentsStore();
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0);

const [showAddDialog, setShowAddDialog] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
Expand All @@ -116,13 +117,21 @@ export function Agents() {
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
setChannelGroups(response.channels || []);
} catch {
setChannelGroups([]);
// Keep the last rendered snapshot when channel account refresh fails.
}
}, []);

useEffect(() => {
let mounted = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => {
if (mounted) {
setHasCompletedInitialLoad(true);
Comment on lines +125 to +129
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasCompletedInitialLoad is only set after Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]) settles, but stable snapshot persistence is gated on hasCompletedInitialLoad && !loading. This can delay persisting a stable snapshot even after agents are already loaded/rendered, so a user refresh during that window can still fall back to the blocking spinner because hasStableValue is still false. Consider persisting as soon as the primary page data (agents/channelGroups) is available, rather than waiting on the provider snapshot.

Copilot uses AI. Check for mistakes.
}
});
return () => {
mounted = false;
};
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);

useEffect(() => {
Expand Down Expand Up @@ -150,11 +159,15 @@ export function Agents() {
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
[activeAgentId, agents],
);

const visibleAgents = agents;
const visibleChannelGroups = channelGroups;
const isUsingStableValue = loading && hasCompletedInitialLoad;
const handleRefresh = () => {
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
};

if (loading) {
if (loading && !hasCompletedInitialLoad) {
return (
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
<LoadingSpinner size="lg" />
Expand All @@ -163,7 +176,7 @@ export function Agents() {
}

return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div data-testid="agents-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
Expand All @@ -181,7 +194,7 @@ export function Agents() {
onClick={handleRefresh}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
{t('refresh')}
</Button>
<Button
Expand Down Expand Up @@ -214,11 +227,11 @@ export function Agents() {
)}

<div className="space-y-3">
{agents.map((agent) => (
{visibleAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
channelGroups={channelGroups}
channelGroups={visibleChannelGroups}
onOpenSettings={() => setActiveAgentId(agent.id)}
onDelete={() => setAgentToDelete(agent)}
/>
Expand All @@ -241,7 +254,7 @@ export function Agents() {
{activeAgent && (
<AgentSettingsModal
agent={activeAgent}
channelGroups={channelGroups}
channelGroups={visibleChannelGroups}
onClose={() => setActiveAgentId(null)}
/>
)}
Expand Down
24 changes: 14 additions & 10 deletions src/pages/Channels/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export function Channels() {
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);

const displayedChannelTypes = getPrimaryChannels();
const visibleChannelGroups = channelGroups;
const visibleAgents = agents;
const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0;
const isUsingStableValue = hasStableValue && (loading || Boolean(error));

const fetchPageData = useCallback(async () => {
setLoading(true);
Expand Down Expand Up @@ -143,21 +147,21 @@ export function Channels() {
}, [fetchPageData, gatewayStatus.state]);

const configuredTypes = useMemo(
() => channelGroups.map((group) => group.channelType),
[channelGroups],
() => visibleChannelGroups.map((group) => group.channelType),
[visibleChannelGroups],
);

const groupedByType = useMemo(() => {
return Object.fromEntries(channelGroups.map((group) => [group.channelType, group]));
}, [channelGroups]);
return Object.fromEntries(visibleChannelGroups.map((group) => [group.channelType, group]));
}, [visibleChannelGroups]);

const configuredGroups = useMemo(() => {
const known = displayedChannelTypes
.map((type) => groupedByType[type])
.filter((group): group is ChannelGroupItem => Boolean(group));
const unknown = channelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
const unknown = visibleChannelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
return [...known, ...unknown];
}, [channelGroups, displayedChannelTypes, groupedByType]);
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);

const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));

Expand Down Expand Up @@ -217,7 +221,7 @@ export function Channels() {
return nextAccountId;
};

if (loading) {
if (loading && !hasStableValue) {
return (
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
<LoadingSpinner size="lg" />
Expand All @@ -226,7 +230,7 @@ export function Channels() {
}

return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div data-testid="channels-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
Expand All @@ -245,7 +249,7 @@ export function Channels() {
disabled={gatewayStatus.state !== 'running'}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
{t('refresh')}
</Button>
</div>
Expand Down Expand Up @@ -368,7 +372,7 @@ export function Channels() {
}}
>
<option value="">{t('account.unassigned')}</option>
{agents.map((agent) => (
{visibleAgents.map((agent) => (
<option key={agent.id} value={agent.id}>{agent.name}</option>
))}
</select>
Expand Down
Loading
Loading