Preserve stable snapshots and stabilize Electron e2e (#734)
This commit is contained in:
committed by
GitHub
Unverified
parent
34bbb039d3
commit
5a3da41562
@@ -318,6 +318,7 @@ AI を開発ワークフローに統合できます。エージェントを使
|
|||||||
│ ├── i18n/ # ローカライズリソース
|
│ ├── i18n/ # ローカライズリソース
|
||||||
│ └── types/ # TypeScript 型定義
|
│ └── types/ # TypeScript 型定義
|
||||||
├── tests/
|
├── tests/
|
||||||
|
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
|
||||||
│ └── unit/ # Vitest ユニット/統合寄りテスト
|
│ └── unit/ # Vitest ユニット/統合寄りテスト
|
||||||
├── resources/ # 静的アセット(アイコン、画像)
|
├── resources/ # 静的アセット(アイコン、画像)
|
||||||
└── scripts/ # ビルド/ユーティリティスクリプト
|
└── scripts/ # ビルド/ユーティリティスクリプト
|
||||||
@@ -335,6 +336,8 @@ pnpm typecheck # TypeScriptの型チェック
|
|||||||
|
|
||||||
# テスト
|
# テスト
|
||||||
pnpm test # ユニットテストを実行
|
pnpm test # ユニットテストを実行
|
||||||
|
pnpm run test:e2e # Electron E2E スモークテストを実行
|
||||||
|
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
|
||||||
pnpm run comms:replay # 通信リプレイ指標を算出
|
pnpm run comms:replay # 通信リプレイ指標を算出
|
||||||
pnpm run comms:baseline # 通信ベースラインを更新
|
pnpm run comms:baseline # 通信ベースラインを更新
|
||||||
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
||||||
@@ -348,6 +351,8 @@ pnpm package:win # Windows向けにパッケージ化
|
|||||||
pnpm package:linux # Linux向けにパッケージ化
|
pnpm package:linux # Linux向けにパッケージ化
|
||||||
```
|
```
|
||||||
|
|
||||||
|
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
|
||||||
|
|
||||||
### 通信回帰チェック
|
### 通信回帰チェック
|
||||||
|
|
||||||
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
PR が通信経路(Gateway イベント、Chat 送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
|||||||
│ ├── i18n/ # Localization resources
|
│ ├── i18n/ # Localization resources
|
||||||
│ └── types/ # TypeScript type definitions
|
│ └── types/ # TypeScript type definitions
|
||||||
├── tests/
|
├── tests/
|
||||||
|
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
|
||||||
│ └── unit/ # Vitest unit/integration-like tests
|
│ └── unit/ # Vitest unit/integration-like tests
|
||||||
├── resources/ # Static assets (icons/images)
|
├── resources/ # Static assets (icons/images)
|
||||||
└── scripts/ # Build and utility scripts
|
└── scripts/ # Build and utility scripts
|
||||||
@@ -354,6 +355,8 @@ pnpm package:win # Package for Windows
|
|||||||
pnpm package:linux # Package for Linux
|
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
|
### Communication Regression Checks
|
||||||
|
|
||||||
When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
|
When a PR changes communication paths (gateway events, chat runtime send/receive flow, channel delivery, or transport fallback), run:
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
|||||||
│ ├── i18n/ # 国际化资源
|
│ ├── i18n/ # 国际化资源
|
||||||
│ └── types/ # TypeScript 类型定义
|
│ └── types/ # TypeScript 类型定义
|
||||||
├── tests/
|
├── tests/
|
||||||
|
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
|
||||||
│ └── unit/ # Vitest 单元/集成型测试
|
│ └── unit/ # Vitest 单元/集成型测试
|
||||||
├── resources/ # 静态资源(图标、图片)
|
├── resources/ # 静态资源(图标、图片)
|
||||||
└── scripts/ # 构建与工具脚本
|
└── scripts/ # 构建与工具脚本
|
||||||
@@ -339,6 +340,8 @@ pnpm typecheck # TypeScript 类型检查
|
|||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pnpm test # 运行单元测试
|
pnpm test # 运行单元测试
|
||||||
|
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
|
||||||
|
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
|
||||||
pnpm run comms:replay # 计算通信回放指标
|
pnpm run comms:replay # 计算通信回放指标
|
||||||
pnpm run comms:baseline # 刷新通信基线快照
|
pnpm run comms:baseline # 刷新通信基线快照
|
||||||
pnpm run comms:compare # 将回放指标与基线阈值对比
|
pnpm run comms:compare # 将回放指标与基线阈值对比
|
||||||
@@ -352,6 +355,8 @@ pnpm package:win # 为 Windows 打包
|
|||||||
pnpm package:linux # 为 Linux 打包
|
pnpm package:linux # 为 Linux 打包
|
||||||
```
|
```
|
||||||
|
|
||||||
|
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
|
||||||
|
|
||||||
### 通信回归检查
|
### 通信回归检查
|
||||||
|
|
||||||
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
|
当 PR 涉及通信链路(Gateway 事件、Chat 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ function createWindow(): BrowserWindow {
|
|||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const useCustomTitleBar = isWindows;
|
const useCustomTitleBar = isWindows;
|
||||||
|
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
@@ -195,12 +196,20 @@ function createWindow(): BrowserWindow {
|
|||||||
|
|
||||||
// Load the app
|
// Load the app
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
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) {
|
if (!isE2EMode) {
|
||||||
win.webContents.openDevTools();
|
win.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
win.loadFile(join(__dirname, '../../dist/index.html'), {
|
||||||
|
query: shouldSkipSetupForE2E
|
||||||
|
? { e2eSkipSetup: '1' }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return win;
|
return win;
|
||||||
@@ -246,7 +255,7 @@ function createMainWindow(): BrowserWindow {
|
|||||||
});
|
});
|
||||||
|
|
||||||
win.on('close', (event) => {
|
win.on('close', (event) => {
|
||||||
if (!isQuitting()) {
|
if (!isQuitting() && !isE2EMode) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
win.hide();
|
win.hide();
|
||||||
}
|
}
|
||||||
@@ -546,7 +555,7 @@ if (gotTheLock) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin' || isE2EMode) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,4 +144,4 @@
|
|||||||
"zx": "^8.8.5"
|
"zx": "^8.8.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||||
}
|
}
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -48,7 +48,7 @@ importers:
|
|||||||
version: 2026.3.30(openclaw@2026.3.28(@napi-rs/canvas@0.1.97)(encoding@0.1.13))
|
version: 2026.3.30(openclaw@2026.3.28(@napi-rs/canvas@0.1.97)(encoding@0.1.13))
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.56.1
|
specifier: ^1.56.1
|
||||||
version: 1.58.2
|
version: 1.59.0
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -1489,8 +1489,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.59.0':
|
||||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -4948,8 +4948,13 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
playwright@1.58.2:
|
playwright-core@1.59.0:
|
||||||
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.59.0:
|
||||||
|
resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -7826,9 +7831,9 @@ snapshots:
|
|||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.59.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.58.2
|
playwright: 1.59.0
|
||||||
|
|
||||||
'@posthog/core@1.24.1':
|
'@posthog/core@1.24.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11973,9 +11978,11 @@ snapshots:
|
|||||||
|
|
||||||
playwright-core@1.58.2: {}
|
playwright-core@1.58.2: {}
|
||||||
|
|
||||||
playwright@1.58.2:
|
playwright-core@1.59.0: {}
|
||||||
|
|
||||||
|
playwright@1.59.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright-core: 1.58.2
|
playwright-core: 1.59.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class ErrorBoundary extends Component<
|
|||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const skipSetupForE2E = typeof window !== 'undefined'
|
||||||
|
&& new URLSearchParams(window.location.search).get('e2eSkipSetup') === '1';
|
||||||
const initSettings = useSettingsStore((state) => state.init);
|
const initSettings = useSettingsStore((state) => state.init);
|
||||||
const theme = useSettingsStore((state) => state.theme);
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
const language = useSettingsStore((state) => state.language);
|
const language = useSettingsStore((state) => state.language);
|
||||||
@@ -120,10 +122,10 @@ function App() {
|
|||||||
|
|
||||||
// Redirect to setup wizard if not complete
|
// Redirect to setup wizard if not complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setupComplete && !location.pathname.startsWith('/setup')) {
|
if (!setupComplete && !skipSetupForE2E && !location.pathname.startsWith('/setup')) {
|
||||||
navigate('/setup');
|
navigate('/setup');
|
||||||
}
|
}
|
||||||
}, [setupComplete, location.pathname, navigate]);
|
}, [setupComplete, skipSetupForE2E, location.pathname, navigate]);
|
||||||
|
|
||||||
// Listen for navigation events from main process
|
// Listen for navigation events from main process
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export function Agents() {
|
|||||||
deleteAgent,
|
deleteAgent,
|
||||||
} = useAgentsStore();
|
} = useAgentsStore();
|
||||||
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
|
||||||
|
const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0);
|
||||||
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||||
@@ -116,13 +117,21 @@ export function Agents() {
|
|||||||
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
|
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
|
||||||
setChannelGroups(response.channels || []);
|
setChannelGroups(response.channels || []);
|
||||||
} catch {
|
} catch {
|
||||||
setChannelGroups([]);
|
// Keep the last rendered snapshot when channel account refresh fails.
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
|
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,11 +159,15 @@ export function Agents() {
|
|||||||
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
|
||||||
[activeAgentId, agents],
|
[activeAgentId, agents],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const visibleAgents = agents;
|
||||||
|
const visibleChannelGroups = channelGroups;
|
||||||
|
const isUsingStableValue = loading && hasCompletedInitialLoad;
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading && !hasCompletedInitialLoad) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
@@ -163,7 +176,7 @@ export function Agents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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 className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -181,7 +194,7 @@ export function Agents() {
|
|||||||
onClick={handleRefresh}
|
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"
|
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')}
|
{t('refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -214,11 +227,11 @@ export function Agents() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{agents.map((agent) => (
|
{visibleAgents.map((agent) => (
|
||||||
<AgentCard
|
<AgentCard
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
channelGroups={channelGroups}
|
channelGroups={visibleChannelGroups}
|
||||||
onOpenSettings={() => setActiveAgentId(agent.id)}
|
onOpenSettings={() => setActiveAgentId(agent.id)}
|
||||||
onDelete={() => setAgentToDelete(agent)}
|
onDelete={() => setAgentToDelete(agent)}
|
||||||
/>
|
/>
|
||||||
@@ -241,7 +254,7 @@ export function Agents() {
|
|||||||
{activeAgent && (
|
{activeAgent && (
|
||||||
<AgentSettingsModal
|
<AgentSettingsModal
|
||||||
agent={activeAgent}
|
agent={activeAgent}
|
||||||
channelGroups={channelGroups}
|
channelGroups={visibleChannelGroups}
|
||||||
onClose={() => setActiveAgentId(null)}
|
onClose={() => setActiveAgentId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export function Channels() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||||
|
|
||||||
const displayedChannelTypes = getPrimaryChannels();
|
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 () => {
|
const fetchPageData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -143,21 +147,21 @@ export function Channels() {
|
|||||||
}, [fetchPageData, gatewayStatus.state]);
|
}, [fetchPageData, gatewayStatus.state]);
|
||||||
|
|
||||||
const configuredTypes = useMemo(
|
const configuredTypes = useMemo(
|
||||||
() => channelGroups.map((group) => group.channelType),
|
() => visibleChannelGroups.map((group) => group.channelType),
|
||||||
[channelGroups],
|
[visibleChannelGroups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedByType = useMemo(() => {
|
const groupedByType = useMemo(() => {
|
||||||
return Object.fromEntries(channelGroups.map((group) => [group.channelType, group]));
|
return Object.fromEntries(visibleChannelGroups.map((group) => [group.channelType, group]));
|
||||||
}, [channelGroups]);
|
}, [visibleChannelGroups]);
|
||||||
|
|
||||||
const configuredGroups = useMemo(() => {
|
const configuredGroups = useMemo(() => {
|
||||||
const known = displayedChannelTypes
|
const known = displayedChannelTypes
|
||||||
.map((type) => groupedByType[type])
|
.map((type) => groupedByType[type])
|
||||||
.filter((group): group is ChannelGroupItem => Boolean(group));
|
.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];
|
return [...known, ...unknown];
|
||||||
}, [channelGroups, displayedChannelTypes, groupedByType]);
|
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);
|
||||||
|
|
||||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||||
|
|
||||||
@@ -217,7 +221,7 @@ export function Channels() {
|
|||||||
return nextAccountId;
|
return nextAccountId;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading && !hasStableValue) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
|
||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
@@ -226,7 +230,7 @@ export function Channels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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 className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -245,7 +249,7 @@ export function Channels() {
|
|||||||
disabled={gatewayStatus.state !== 'running'}
|
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"
|
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')}
|
{t('refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +372,7 @@ export function Channels() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">{t('account.unassigned')}</option>
|
<option value="">{t('account.unassigned')}</option>
|
||||||
{agents.map((agent) => (
|
{visibleAgents.map((agent) => (
|
||||||
<option key={agent.id} value={agent.id}>{agent.name}</option>
|
<option key={agent.id} value={agent.id}>{agent.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { FeedbackState } from '@/components/common/FeedbackState';
|
|||||||
import {
|
import {
|
||||||
filterUsageHistoryByWindow,
|
filterUsageHistoryByWindow,
|
||||||
groupUsageHistory,
|
groupUsageHistory,
|
||||||
|
resolveStableUsageHistory,
|
||||||
|
resolveVisibleUsageHistory,
|
||||||
type UsageGroupBy,
|
type UsageGroupBy,
|
||||||
type UsageHistoryEntry,
|
type UsageHistoryEntry,
|
||||||
type UsageWindow,
|
type UsageWindow,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
|
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
|
||||||
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
|
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
|
||||||
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
||||||
|
const USAGE_AUTO_REFRESH_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
export function Models() {
|
export function Models() {
|
||||||
const { t } = useTranslation(['dashboard', 'settings']);
|
const { t } = useTranslation(['dashboard', 'settings']);
|
||||||
@@ -36,6 +39,7 @@ export function Models() {
|
|||||||
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
|
||||||
const [usagePage, setUsagePage] = useState(1);
|
const [usagePage, setUsagePage] = useState(1);
|
||||||
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
|
||||||
|
const [usageRefreshNonce, setUsageRefreshNonce] = useState(0);
|
||||||
const HIDDEN_USAGE_SOURCES = new Set([
|
const HIDDEN_USAGE_SOURCES = new Set([
|
||||||
'gateway-injected',
|
'gateway-injected',
|
||||||
'delivery-mirror',
|
'delivery-mirror',
|
||||||
@@ -71,35 +75,79 @@ export function Models() {
|
|||||||
type FetchState = {
|
type FetchState = {
|
||||||
status: 'idle' | 'loading' | 'done';
|
status: 'idle' | 'loading' | 'done';
|
||||||
data: UsageHistoryEntry[];
|
data: UsageHistoryEntry[];
|
||||||
|
stableData: UsageHistoryEntry[];
|
||||||
};
|
};
|
||||||
type FetchAction =
|
type FetchAction =
|
||||||
| { type: 'start' }
|
| { type: 'start' }
|
||||||
| { type: 'done'; data: UsageHistoryEntry[] }
|
| { type: 'done'; data: UsageHistoryEntry[] }
|
||||||
|
| { type: 'failed' }
|
||||||
| { type: 'reset' };
|
| { type: 'reset' };
|
||||||
|
|
||||||
const [fetchState, dispatchFetch] = useReducer(
|
const [fetchState, dispatchFetch] = useReducer(
|
||||||
(state: FetchState, action: FetchAction): FetchState => {
|
(state: FetchState, action: FetchAction): FetchState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'start':
|
case 'start':
|
||||||
return { status: 'loading', data: state.data };
|
return { ...state, status: 'loading' };
|
||||||
case 'done':
|
case 'done':
|
||||||
return { status: 'done', data: action.data };
|
return {
|
||||||
|
status: 'done',
|
||||||
|
data: action.data,
|
||||||
|
stableData: resolveStableUsageHistory(state.stableData, action.data),
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return { ...state, status: 'done' };
|
||||||
case 'reset':
|
case 'reset':
|
||||||
return { status: 'idle', data: [] };
|
return { status: 'idle', data: [], stableData: [] };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ status: 'idle' as const, data: [] as UsageHistoryEntry[] },
|
{ status: 'idle' as const, data: [] as UsageHistoryEntry[], stableData: [] as UsageHistoryEntry[] },
|
||||||
);
|
);
|
||||||
|
|
||||||
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const usageFetchGenerationRef = useRef(0);
|
const usageFetchGenerationRef = useRef(0);
|
||||||
|
const usageFetchStatusRef = useRef<FetchState['status']>('idle');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usageFetchStatusRef.current = fetchState.status;
|
||||||
|
}, [fetchState.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackUiEvent('models.page_viewed');
|
trackUiEvent('models.page_viewed');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGatewayRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRefresh = () => {
|
||||||
|
if (usageFetchStatusRef.current === 'loading') return;
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||||
|
setUsageRefreshNonce((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(requestRefresh, USAGE_AUTO_REFRESH_INTERVAL_MS);
|
||||||
|
const handleFocus = () => {
|
||||||
|
requestRefresh();
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
requestRefresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', handleFocus);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
window.removeEventListener('focus', handleFocus);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [isGatewayRunning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (usageFetchTimerRef.current) {
|
if (usageFetchTimerRef.current) {
|
||||||
clearTimeout(usageFetchTimerRef.current);
|
clearTimeout(usageFetchTimerRef.current);
|
||||||
@@ -128,7 +176,7 @@ export function Models() {
|
|||||||
generation,
|
generation,
|
||||||
restartMarker,
|
restartMarker,
|
||||||
});
|
});
|
||||||
dispatchFetch({ type: 'done', data: [] });
|
dispatchFetch({ type: 'failed' });
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
const fetchUsageHistoryWithRetry = async (attempt: number) => {
|
const fetchUsageHistoryWithRetry = async (attempt: number) => {
|
||||||
@@ -191,7 +239,7 @@ export function Models() {
|
|||||||
}, USAGE_FETCH_RETRY_DELAY_MS);
|
}, USAGE_FETCH_RETRY_DELAY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatchFetch({ type: 'done', data: [] });
|
dispatchFetch({ type: 'failed' });
|
||||||
trackUiEvent('models.token_usage_fetch_exhausted', {
|
trackUiEvent('models.token_usage_fetch_exhausted', {
|
||||||
generation,
|
generation,
|
||||||
attempt,
|
attempt,
|
||||||
@@ -210,18 +258,25 @@ export function Models() {
|
|||||||
usageFetchTimerRef.current = null;
|
usageFetchTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]);
|
||||||
|
|
||||||
const visibleUsageHistory = isGatewayRunning
|
const usageHistory = isGatewayRunning
|
||||||
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
|
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
|
||||||
: [];
|
: [];
|
||||||
|
const stableUsageHistory = isGatewayRunning
|
||||||
|
? fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry))
|
||||||
|
: [];
|
||||||
|
const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory, {
|
||||||
|
preferStableOnEmpty: isGatewayRunning && fetchState.status === 'loading',
|
||||||
|
});
|
||||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||||
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
|
||||||
const usagePageSize = 5;
|
const usagePageSize = 5;
|
||||||
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
|
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
|
||||||
const safeUsagePage = Math.min(usagePage, usageTotalPages);
|
const safeUsagePage = Math.min(usagePage, usageTotalPages);
|
||||||
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
|
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
|
||||||
const usageLoading = isGatewayRunning && fetchState.status === 'loading';
|
const usageLoading = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length === 0;
|
||||||
|
const usageRefreshing = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||||
@@ -328,7 +383,9 @@ export function Models() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium text-muted-foreground">
|
<p className="text-[13px] font-medium text-muted-foreground">
|
||||||
{t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
{usageRefreshing
|
||||||
|
? t('dashboard:recentTokenHistory.loading')
|
||||||
|
: t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,30 @@ export type UsageGroup = {
|
|||||||
sortKey: number | string;
|
sortKey: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function resolveStableUsageHistory(
|
||||||
|
previousStableEntries: UsageHistoryEntry[],
|
||||||
|
nextEntries: UsageHistoryEntry[],
|
||||||
|
options: { preservePreviousOnEmpty?: boolean } = {},
|
||||||
|
): UsageHistoryEntry[] {
|
||||||
|
if (nextEntries.length > 0) {
|
||||||
|
return nextEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.preservePreviousOnEmpty ? previousStableEntries : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleUsageHistory(
|
||||||
|
currentEntries: UsageHistoryEntry[],
|
||||||
|
stableEntries: UsageHistoryEntry[],
|
||||||
|
options: { preferStableOnEmpty?: boolean } = {},
|
||||||
|
): UsageHistoryEntry[] {
|
||||||
|
if (options.preferStableOnEmpty && currentEntries.length === 0) {
|
||||||
|
return stableEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentEntries;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUsageDay(timestamp: string): string {
|
export function formatUsageDay(timestamp: string): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
if (Number.isNaN(date.getTime())) return timestamp;
|
if (Number.isNaN(date.getTime())) return timestamp;
|
||||||
|
|||||||
@@ -1316,11 +1316,48 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
const loadPromise = (async () => {
|
const loadPromise = (async () => {
|
||||||
|
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
||||||
|
const getPreviewMergeKey = (message: RawMessage): string => (
|
||||||
|
`${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}`
|
||||||
|
);
|
||||||
|
const mergeHydratedMessages = (
|
||||||
|
currentMessages: RawMessage[],
|
||||||
|
hydratedMessages: RawMessage[],
|
||||||
|
): RawMessage[] => {
|
||||||
|
const hydratedFilesByKey = new Map(
|
||||||
|
hydratedMessages
|
||||||
|
.filter((message) => message._attachedFiles?.length)
|
||||||
|
.map((message) => [
|
||||||
|
getPreviewMergeKey(message),
|
||||||
|
message._attachedFiles!.map((file) => ({ ...file })),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return currentMessages.map((message) => {
|
||||||
|
const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message));
|
||||||
|
return attachedFiles
|
||||||
|
? { ...message, _attachedFiles: attachedFiles }
|
||||||
|
: message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLoadFailure = (errorMessage: string | null) => {
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
|
set((state) => {
|
||||||
|
const hasMessages = state.messages.length > 0;
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
error: !quiet && errorMessage ? errorMessage : state.error,
|
||||||
|
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
||||||
// Guard: if the user switched sessions while this async load was in
|
// Guard: if the user switched sessions while this async load was in
|
||||||
// flight, discard the result to prevent overwriting the new session's
|
// flight, discard the result to prevent overwriting the new session's
|
||||||
// messages with stale data from the old session.
|
// messages with stale data from the old session.
|
||||||
if (get().currentSessionKey !== currentSessionKey) return;
|
if (!isCurrentSession()) return;
|
||||||
|
|
||||||
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||||
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||||
@@ -1379,17 +1416,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
// Async: load missing image previews from disk (updates in background)
|
// Async: load missing image previews from disk (updates in background)
|
||||||
loadMissingPreviews(finalMessages).then((updated) => {
|
loadMissingPreviews(finalMessages).then((updated) => {
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
if (updated) {
|
if (updated) {
|
||||||
// Create new object references so React.memo detects changes.
|
set((state) => ({
|
||||||
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
messages: mergeHydratedMessages(state.messages, finalMessages),
|
||||||
// must produce fresh message + file references for each affected msg.
|
}));
|
||||||
set({
|
|
||||||
messages: finalMessages.map(msg =>
|
|
||||||
msg._attachedFiles
|
|
||||||
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
||||||
@@ -1445,7 +1476,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
applyLoadedMessages(fallbackMessages, null);
|
||||||
} else {
|
} else {
|
||||||
set({ messages: [], loading: false });
|
applyLoadFailure('Failed to load chat history');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1454,7 +1485,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
applyLoadedMessages(fallbackMessages, null);
|
||||||
} else {
|
} else {
|
||||||
set({ messages: [], loading: false });
|
applyLoadFailure(String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -37,7 +37,45 @@ export function createHistoryActions(
|
|||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
if (!quiet) set({ loading: true, error: null });
|
if (!quiet) set({ loading: true, error: null });
|
||||||
|
|
||||||
|
const isCurrentSession = () => get().currentSessionKey === currentSessionKey;
|
||||||
|
const getPreviewMergeKey = (message: RawMessage): string => (
|
||||||
|
`${message.id ?? ''}|${message.role}|${message.timestamp ?? ''}|${getMessageText(message.content)}`
|
||||||
|
);
|
||||||
|
const mergeHydratedMessages = (
|
||||||
|
currentMessages: RawMessage[],
|
||||||
|
hydratedMessages: RawMessage[],
|
||||||
|
): RawMessage[] => {
|
||||||
|
const hydratedFilesByKey = new Map(
|
||||||
|
hydratedMessages
|
||||||
|
.filter((message) => message._attachedFiles?.length)
|
||||||
|
.map((message) => [
|
||||||
|
getPreviewMergeKey(message),
|
||||||
|
message._attachedFiles!.map((file) => ({ ...file })),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return currentMessages.map((message) => {
|
||||||
|
const attachedFiles = hydratedFilesByKey.get(getPreviewMergeKey(message));
|
||||||
|
return attachedFiles
|
||||||
|
? { ...message, _attachedFiles: attachedFiles }
|
||||||
|
: message;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLoadFailure = (errorMessage: string | null) => {
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
|
set((state) => {
|
||||||
|
const hasMessages = state.messages.length > 0;
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
error: !quiet && errorMessage ? errorMessage : state.error,
|
||||||
|
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||||
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
|
||||||
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg));
|
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role) && !isInternalMessage(msg));
|
||||||
@@ -95,17 +133,11 @@ export function createHistoryActions(
|
|||||||
|
|
||||||
// Async: load missing image previews from disk (updates in background)
|
// Async: load missing image previews from disk (updates in background)
|
||||||
loadMissingPreviews(finalMessages).then((updated) => {
|
loadMissingPreviews(finalMessages).then((updated) => {
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
if (updated) {
|
if (updated) {
|
||||||
// Create new object references so React.memo detects changes.
|
set((state) => ({
|
||||||
// loadMissingPreviews mutates AttachedFileMeta in place, so we
|
messages: mergeHydratedMessages(state.messages, finalMessages),
|
||||||
// must produce fresh message + file references for each affected msg.
|
}));
|
||||||
set({
|
|
||||||
messages: finalMessages.map(msg =>
|
|
||||||
msg._attachedFiles
|
|
||||||
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
|
|
||||||
: msg
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
|
||||||
@@ -163,7 +195,7 @@ export function createHistoryActions(
|
|||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
applyLoadedMessages(fallbackMessages, null);
|
||||||
} else {
|
} else {
|
||||||
set({ messages: [], loading: false });
|
applyLoadFailure(result.error || 'Failed to load chat history');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -172,7 +204,7 @@ export function createHistoryActions(
|
|||||||
if (fallbackMessages.length > 0) {
|
if (fallbackMessages.length > 0) {
|
||||||
applyLoadedMessages(fallbackMessages, null);
|
applyLoadedMessages(fallbackMessages, null);
|
||||||
} else {
|
} else {
|
||||||
set({ messages: [], loading: false });
|
applyLoadFailure(String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from './fixtures/electron';
|
import { closeElectronApp, expect, test } from './fixtures/electron';
|
||||||
|
|
||||||
test.describe('ClawX Electron smoke flows', () => {
|
test.describe('ClawX Electron smoke flows', () => {
|
||||||
test('shows the setup wizard on a fresh profile', async ({ page }) => {
|
test('shows the setup wizard on a fresh profile', async ({ page }) => {
|
||||||
@@ -25,7 +25,7 @@ test.describe('ClawX Electron smoke flows', () => {
|
|||||||
await firstWindow.getByTestId('setup-skip-button').click();
|
await firstWindow.getByTestId('setup-skip-button').click();
|
||||||
await expect(firstWindow.getByTestId('main-layout')).toBeVisible();
|
await expect(firstWindow.getByTestId('main-layout')).toBeVisible();
|
||||||
|
|
||||||
await electronApp.close();
|
await closeElectronApp(electronApp);
|
||||||
|
|
||||||
const relaunchedApp = await launchElectronApp();
|
const relaunchedApp = await launchElectronApp();
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +35,7 @@ test.describe('ClawX Electron smoke flows', () => {
|
|||||||
await expect(relaunchedWindow.getByTestId('main-layout')).toBeVisible();
|
await expect(relaunchedWindow.getByTestId('main-layout')).toBeVisible();
|
||||||
await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0);
|
await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await relaunchedApp.close();
|
await closeElectronApp(relaunchedApp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { createServer } from 'node:net';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
type LaunchElectronOptions = {
|
||||||
|
skipSetup?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type ElectronFixtures = {
|
type ElectronFixtures = {
|
||||||
electronApp: ElectronApplication;
|
electronApp: ElectronApplication;
|
||||||
page: Page;
|
page: Page;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
userDataDir: string;
|
userDataDir: string;
|
||||||
launchElectronApp: () => Promise<ElectronApplication>;
|
launchElectronApp: (options?: LaunchElectronOptions) => Promise<ElectronApplication>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const repoRoot = resolve(process.cwd());
|
const repoRoot = resolve(process.cwd());
|
||||||
@@ -38,7 +42,77 @@ async function allocatePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchClawXElectron(homeDir: string, userDataDir: string): Promise<ElectronApplication> {
|
async function getStableWindow(app: ElectronApplication): Promise<Page> {
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
let page = await app.firstWindow();
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const openWindows = app.windows().filter((candidate) => !candidate.isClosed());
|
||||||
|
const currentWindow = openWindows.at(-1) ?? page;
|
||||||
|
|
||||||
|
if (currentWindow && !currentWindow.isClosed()) {
|
||||||
|
try {
|
||||||
|
await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 });
|
||||||
|
return currentWindow;
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error).includes('has been closed')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
page = await app.waitForEvent('window', { timeout: 2_000 });
|
||||||
|
} catch {
|
||||||
|
// Keep polling until a stable window is available or the deadline expires.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No stable Electron window became available');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeElectronApp(app: ElectronApplication, timeoutMs = 5_000): Promise<void> {
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
const [closeResult] = await Promise.allSettled([
|
||||||
|
app.waitForEvent('close', { timeout: timeoutMs }),
|
||||||
|
app.evaluate(({ app: electronApp }) => {
|
||||||
|
electronApp.quit();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (closeResult.status === 'fulfilled') {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to process kill if Playwright cannot close the app cleanly.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.process().kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// Ignore process kill failures during e2e teardown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchClawXElectron(
|
||||||
|
homeDir: string,
|
||||||
|
userDataDir: string,
|
||||||
|
options: LaunchElectronOptions = {},
|
||||||
|
): Promise<ElectronApplication> {
|
||||||
const hostApiPort = await allocatePort();
|
const hostApiPort = await allocatePort();
|
||||||
const electronEnv = process.platform === 'linux'
|
const electronEnv = process.platform === 'linux'
|
||||||
? { ELECTRON_DISABLE_SANDBOX: '1' }
|
? { ELECTRON_DISABLE_SANDBOX: '1' }
|
||||||
@@ -56,6 +130,7 @@ async function launchClawXElectron(homeDir: string, userDataDir: string): Promis
|
|||||||
XDG_CONFIG_HOME: join(homeDir, '.config'),
|
XDG_CONFIG_HOME: join(homeDir, '.config'),
|
||||||
CLAWX_E2E: '1',
|
CLAWX_E2E: '1',
|
||||||
CLAWX_USER_DATA_DIR: userDataDir,
|
CLAWX_USER_DATA_DIR: userDataDir,
|
||||||
|
...(options.skipSetup ? { CLAWX_E2E_SKIP_SETUP: '1' } : {}),
|
||||||
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
|
CLAWX_PORT_CLAWX_HOST_API: String(hostApiPort),
|
||||||
},
|
},
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
@@ -85,7 +160,7 @@ export const test = base.extend<ElectronFixtures>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => {
|
launchElectronApp: async ({ homeDir, userDataDir }, provideLauncher) => {
|
||||||
await provideLauncher(async () => await launchClawXElectron(homeDir, userDataDir));
|
await provideLauncher(async (options?: LaunchElectronOptions) => await launchClawXElectron(homeDir, userDataDir, options));
|
||||||
},
|
},
|
||||||
|
|
||||||
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
|
electronApp: async ({ launchElectronApp }, provideElectronApp) => {
|
||||||
@@ -99,14 +174,13 @@ export const test = base.extend<ElectronFixtures>({
|
|||||||
await provideElectronApp(app);
|
await provideElectronApp(app);
|
||||||
} finally {
|
} finally {
|
||||||
if (!appClosed) {
|
if (!appClosed) {
|
||||||
await app.close().catch(() => {});
|
await closeElectronApp(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
page: async ({ electronApp }, providePage) => {
|
page: async ({ electronApp }, providePage) => {
|
||||||
const page = await electronApp.firstWindow();
|
const page = await getStableWindow(electronApp);
|
||||||
await page.waitForLoadState('domcontentloaded');
|
|
||||||
await providePage(page);
|
await providePage(page);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -117,4 +191,6 @@ export async function completeSetup(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { closeElectronApp };
|
||||||
|
export { getStableWindow };
|
||||||
export { expect };
|
export { expect };
|
||||||
|
|||||||
25
tests/e2e/main-navigation.spec.ts
Normal file
25
tests/e2e/main-navigation.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
test.describe('ClawX main navigation without setup flow', () => {
|
||||||
|
test('navigates between core pages with setup bypassed', async ({ launchElectronApp }) => {
|
||||||
|
const app = await launchElectronApp({ skipSetup: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await getStableWindow(app);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-models').click();
|
||||||
|
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('models-page-title')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-agents').click();
|
||||||
|
await expect(page.getByTestId('agents-page')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-channels').click();
|
||||||
|
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await closeElectronApp(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -214,4 +214,46 @@ describe('Agents page status refresh', () => {
|
|||||||
expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6');
|
expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6');
|
||||||
expect(useDefaultButton).toBeDisabled();
|
expect(useDefaultButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the last agent snapshot visible while a refresh is in flight', async () => {
|
||||||
|
agentsState.agents = [
|
||||||
|
{
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
isDefault: true,
|
||||||
|
modelDisplay: 'gpt-5',
|
||||||
|
modelRef: 'openai/gpt-5',
|
||||||
|
overrideModelRef: null,
|
||||||
|
inheritedModel: true,
|
||||||
|
workspace: '~/.openclaw/workspace',
|
||||||
|
agentDir: '~/.openclaw/agents/main/agent',
|
||||||
|
mainSessionKey: 'agent:main:main',
|
||||||
|
channelTypes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(<Agents />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Main')).toBeInTheDocument();
|
||||||
|
|
||||||
|
agentsState.loading = true;
|
||||||
|
await act(async () => {
|
||||||
|
rerender(<Agents />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Main')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the blocking spinner during the initial load before any stable snapshot exists', async () => {
|
||||||
|
agentsState.loading = true;
|
||||||
|
fetchAgentsMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
refreshProviderSnapshotMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
hostApiFetchMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const { container } = render(<Agents />);
|
||||||
|
|
||||||
|
expect(container.querySelector('svg.animate-spin')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('title')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ vi.mock('sonner', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
describe('Channels page status refresh', () => {
|
describe('Channels page status refresh', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -180,4 +188,86 @@ describe('Channels page status refresh', () => {
|
|||||||
|
|
||||||
expect(screen.queryByLabelText('account.customIdLabel')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('account.customIdLabel')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the last channel snapshot visible while refresh is pending', async () => {
|
||||||
|
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||||
|
|
||||||
|
const channelsDeferred = createDeferred<{
|
||||||
|
success: boolean;
|
||||||
|
channels: Array<Record<string, unknown>>;
|
||||||
|
}>();
|
||||||
|
const agentsDeferred = createDeferred<{
|
||||||
|
success: boolean;
|
||||||
|
agents: Array<Record<string, unknown>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let refreshCallCount = 0;
|
||||||
|
hostApiFetchMock.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/channels/accounts') {
|
||||||
|
if (refreshCallCount === 0) {
|
||||||
|
refreshCallCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return channelsDeferred.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/agents') {
|
||||||
|
if (refreshCallCount === 1) {
|
||||||
|
return Promise.resolve({ success: true, agents: [] });
|
||||||
|
}
|
||||||
|
return agentsDeferred.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected host API path: ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Channels />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Feishu / Lark')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'refresh' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Feishu / Lark')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
channelsDeferred.resolve({
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
agentsDeferred.resolve({ success: true, agents: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ describe('chat history actions', () => {
|
|||||||
expect(h.read().loading).toBe(false);
|
expect(h.read().loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves existing messages when history refresh fails for the current session', async () => {
|
||||||
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'still here',
|
||||||
|
timestamp: 1773281732,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const actions = createHistoryActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
invokeIpcMock.mockRejectedValueOnce(new Error('Gateway unavailable'));
|
||||||
|
|
||||||
|
await actions.loadHistory();
|
||||||
|
|
||||||
|
expect(h.read().messages.map((message) => message.content)).toEqual(['still here']);
|
||||||
|
expect(h.read().error).toBe('Error: Gateway unavailable');
|
||||||
|
expect(h.read().loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters out system messages from loaded history', async () => {
|
it('filters out system messages from loaded history', async () => {
|
||||||
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
const h = makeHarness();
|
const h = makeHarness();
|
||||||
@@ -231,4 +254,117 @@ describe('chat history actions', () => {
|
|||||||
'HEARTBEAT_OK is a status code',
|
'HEARTBEAT_OK is a status code',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops stale history results after the user switches sessions', async () => {
|
||||||
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
|
let resolveHistory: ((value: unknown) => void) | null = null;
|
||||||
|
invokeIpcMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveHistory = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:session-a',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'session b content',
|
||||||
|
timestamp: 1773281732,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const actions = createHistoryActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
const loadPromise = actions.loadHistory();
|
||||||
|
h.set({
|
||||||
|
currentSessionKey: 'agent:main:session-b',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'session b content',
|
||||||
|
timestamp: 1773281733,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
resolveHistory?.({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'stale session a content',
|
||||||
|
timestamp: 1773281734,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadPromise;
|
||||||
|
|
||||||
|
expect(h.read().currentSessionKey).toBe('agent:main:session-b');
|
||||||
|
expect(h.read().messages.map((message) => message.content)).toEqual(['session b content']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves newer same-session messages when preview hydration finishes later', async () => {
|
||||||
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
|
let releasePreviewHydration: (() => void) | null = null;
|
||||||
|
loadMissingPreviews.mockImplementationOnce(async (messages) => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releasePreviewHydration = () => {
|
||||||
|
messages[0]!._attachedFiles = [
|
||||||
|
{
|
||||||
|
fileName: 'image.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
fileSize: 42,
|
||||||
|
preview: 'data:image/png;base64,abc',
|
||||||
|
filePath: '/tmp/image.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'history-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'older message',
|
||||||
|
timestamp: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
});
|
||||||
|
const actions = createHistoryActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
await actions.loadHistory();
|
||||||
|
|
||||||
|
h.set((state) => ({
|
||||||
|
messages: [
|
||||||
|
...state.messages,
|
||||||
|
{
|
||||||
|
id: 'newer-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'newer message',
|
||||||
|
timestamp: 1001,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
releasePreviewHydration?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(h.read().messages.map((message) => message.content)).toEqual([
|
||||||
|
'older message',
|
||||||
|
'newer message',
|
||||||
|
]);
|
||||||
|
expect(h.read().messages[0]?._attachedFiles?.[0]?.preview).toBe('data:image/png;base64,abc');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
96
tests/unit/models-page.test.tsx
Normal file
96
tests/unit/models-page.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { act, render } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Models } from '@/pages/Models/index';
|
||||||
|
|
||||||
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const trackUiEventMock = vi.fn();
|
||||||
|
|
||||||
|
const { gatewayState, settingsState } = vi.hoisted(() => ({
|
||||||
|
gatewayState: {
|
||||||
|
status: { state: 'running', port: 18789, connectedAt: 1, pid: 1234 },
|
||||||
|
},
|
||||||
|
settingsState: {
|
||||||
|
devModeUnlocked: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/gateway', () => ({
|
||||||
|
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/settings', () => ({
|
||||||
|
useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/telemetry', () => ({
|
||||||
|
trackUiEvent: (...args: unknown[]) => trackUiEventMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/settings/ProvidersSettings', () => ({
|
||||||
|
ProvidersSettings: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/common/FeedbackState', () => ({
|
||||||
|
FeedbackState: ({ title }: { title: string }) => <div>{title}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, fallback?: string | { count?: number }) => {
|
||||||
|
if (typeof fallback === 'string') return fallback;
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createUsageEntry(totalTokens: number) {
|
||||||
|
return {
|
||||||
|
timestamp: '2026-04-01T12:00:00.000Z',
|
||||||
|
sessionId: `session-${totalTokens}`,
|
||||||
|
agentId: 'main',
|
||||||
|
model: 'gpt-5',
|
||||||
|
provider: 'openai',
|
||||||
|
inputTokens: totalTokens,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Models page auto refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
gatewayState.status = { state: 'running', port: 18789, connectedAt: 1, pid: 1234 };
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'visible',
|
||||||
|
});
|
||||||
|
hostApiFetchMock.mockResolvedValue([createUsageEntry(27)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes token usage while the page stays open', async () => {
|
||||||
|
render(<Models />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(15_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
filterUsageHistoryByWindow,
|
filterUsageHistoryByWindow,
|
||||||
groupUsageHistory,
|
groupUsageHistory,
|
||||||
|
resolveStableUsageHistory,
|
||||||
|
resolveVisibleUsageHistory,
|
||||||
type UsageHistoryEntry,
|
type UsageHistoryEntry,
|
||||||
} from '@/pages/Models/usage-history';
|
} from '@/pages/Models/usage-history';
|
||||||
|
|
||||||
@@ -65,4 +67,25 @@ describe('models usage history helpers', () => {
|
|||||||
expect(filtered).toHaveLength(2);
|
expect(filtered).toHaveLength(2);
|
||||||
expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]);
|
expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears the stable usage snapshot when a successful refresh returns empty', () => {
|
||||||
|
const stable = [createEntry(12, 12)];
|
||||||
|
|
||||||
|
expect(resolveStableUsageHistory(stable, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can preserve the last stable usage snapshot while a refresh is still in flight', () => {
|
||||||
|
const stable = [createEntry(12, 12)];
|
||||||
|
|
||||||
|
expect(resolveStableUsageHistory(stable, [], { preservePreviousOnEmpty: true })).toEqual(stable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers fresh usage entries over the cached snapshot when available', () => {
|
||||||
|
const stable = [createEntry(12, 12)];
|
||||||
|
const fresh = [createEntry(13, 13)];
|
||||||
|
|
||||||
|
expect(resolveVisibleUsageHistory([], stable)).toEqual([]);
|
||||||
|
expect(resolveVisibleUsageHistory([], stable, { preferStableOnEmpty: true })).toEqual(stable);
|
||||||
|
expect(resolveVisibleUsageHistory(fresh, stable, { preferStableOnEmpty: true })).toEqual(fresh);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user