fix: Windows zoom shortcuts and keep chat preview file after workspace tab (#982)

This commit is contained in:
paisley
2026-05-07 11:02:02 +08:00
committed by GitHub
parent 9ada49afb3
commit fc13237023
16 changed files with 585 additions and 39 deletions

View File

@@ -9,6 +9,7 @@ import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
import { createTray } from './tray';
import { createMenu } from './menu';
import { registerZoomShortcuts } from './zoom-shortcuts';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
@@ -182,6 +183,8 @@ function createWindow(): BrowserWindow {
show: false,
});
registerZoomShortcuts(win);
// Handle external links — only allow safe protocols to prevent arbitrary
// command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.)
win.webContents.setWindowOpenHandler(({ url }) => {

View File

@@ -0,0 +1,47 @@
import type { BrowserWindow } from 'electron';
export type ZoomShortcutAction = 'in' | 'out' | 'reset';
type ZoomShortcutInput = Pick<Electron.Input, 'key' | 'code' | 'control' | 'meta' | 'alt'>;
export function getZoomShortcutAction(input: ZoomShortcutInput): ZoomShortcutAction | null {
if ((!input.control && !input.meta) || input.alt) {
return null;
}
const key = input.key.toLowerCase();
if (key === '+' || key === '=' || input.code === 'Equal' || input.code === 'NumpadAdd') {
return 'in';
}
if (key === '-' || input.code === 'Minus' || input.code === 'NumpadSubtract') {
return 'out';
}
if (key === '0' || input.code === 'Digit0' || input.code === 'Numpad0') {
return 'reset';
}
return null;
}
export function registerZoomShortcuts(win: BrowserWindow): void {
win.webContents.on('before-input-event', (event, input) => {
const action = getZoomShortcutAction(input);
if (!action) {
return;
}
event.preventDefault();
if (action === 'reset') {
win.webContents.setZoomLevel(0);
return;
}
const delta = action === 'in' ? 1 : -1;
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + delta);
});
}

View File

@@ -61,11 +61,12 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div data-testid="artifact-panel" className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/5 px-3 py-2 dark:border-white/10">
<div className="flex min-w-0 items-center gap-1">
{richFocusedFile ? (
<PanelTabButton
testId="artifact-panel-action-open-folder"
icon={<FolderOpen className="h-3.5 w-3.5" />}
label={t('generatedFiles.openFolder', '打开文件夹')}
active={false}
@@ -73,6 +74,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
/>
) : (
<PanelTabButton
testId="artifact-panel-tab-changes"
icon={<FileEdit className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.changes', '变更')}
active={visibleTab === 'changes'}
@@ -80,6 +82,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
/>
)}
<PanelTabButton
testId="artifact-panel-tab-preview"
icon={<Eye className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.preview', '预览')}
active={visibleTab === 'preview'}
@@ -87,6 +90,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
/>
{WORKSPACE_BROWSER_ENABLED && (
<PanelTabButton
testId="artifact-panel-tab-browser"
icon={<FolderTree className="h-3.5 w-3.5" />}
label={t('artifactPanel.tabs.browser', '工作空间')}
active={visibleTab === 'browser'}
@@ -129,15 +133,17 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
}
interface PanelTabButtonProps {
testId?: string;
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
}
function PanelTabButton({ icon, label, active, onClick }: PanelTabButtonProps) {
function PanelTabButton({ testId, icon, label, active, onClick }: PanelTabButtonProps) {
return (
<button
data-testid={testId}
type="button"
onClick={onClick}
className={cn(

View File

@@ -4,6 +4,7 @@
"refresh": "Refresh",
"openFolder": "Open Skills Folder",
"gatewayWarning": "Gateway is not running. Skills cannot be loaded without an active Gateway.",
"gatewayStarting": "Gateway is starting. Skills will load automatically once it is ready.",
"tabs": {
"installed": "Installed",
"marketplace": "Marketplace"

View File

@@ -4,6 +4,7 @@
"refresh": "更新",
"openFolder": "スキルフォルダを開く",
"gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。",
"gatewayStarting": "ゲートウェイを起動中です。準備ができしだいスキルを自動で読み込みます。",
"tabs": {
"installed": "インストール済み",
"marketplace": "マーケットプレイス"

View File

@@ -4,6 +4,7 @@
"refresh": "Обновить",
"openFolder": "Открыть папку навыков",
"gatewayWarning": "Шлюз не запущен. Навыки не могут быть загружены без активного шлюза.",
"gatewayStarting": "Шлюз запускается. Навыки загрузятся автоматически, когда он будет готов.",
"tabs": {
"installed": "Установленные",
"marketplace": "Маркетплейс"

View File

@@ -4,6 +4,7 @@
"refresh": "刷新",
"openFolder": "打开技能文件夹",
"gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。",
"gatewayStarting": "网关正在启动。待其就绪后会自动加载技能。",
"tabs": {
"installed": "已安装",
"marketplace": "市场"

View File

@@ -29,6 +29,7 @@ import { hostApiFetch } from '@/lib/host-api';
import { trackUiEvent } from '@/lib/telemetry';
import { toast } from 'sonner';
import type { Skill } from '@/types/skill';
import type { GatewayStatus } from '@/types/gateway';
import { rendererExtensionRegistry } from '@/extensions/registry';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
@@ -54,7 +55,27 @@ const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitErr
const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']);
const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']);
type SkillsGatewayBannerState = 'none' | 'starting' | 'stopped';
function isSkillsGatewayReady(status: GatewayStatus, skillsFeatureReady: boolean): boolean {
return status.state === 'running' && (status.gatewayReady !== false || skillsFeatureReady);
}
function getSkillsGatewayBannerState(
status: GatewayStatus,
skillsFeatureReady: boolean,
): SkillsGatewayBannerState {
if (status.state === 'starting' || status.state === 'reconnecting') {
return 'starting';
}
if (status.state === 'running' && !isSkillsGatewayReady(status, skillsFeatureReady)) {
return 'starting';
}
if (status.state === 'stopped' || status.state === 'error') {
return 'stopped';
}
return 'none';
}
// Skill detail dialog component
interface SkillDetailDialogProps {
@@ -247,28 +268,63 @@ export function Skills() {
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all');
const isGatewayRunning = gatewayStatus.state === 'running';
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
const gatewayRunning = gatewayStatus.state === 'running';
const gatewayReportedReady = gatewayStatus.gatewayReady !== false;
const gatewayRuntimeKey = `${gatewayStatus.pid ?? 'none'}:${gatewayStatus.connectedAt ?? 'none'}:${gatewayStatus.port}`;
const [skillsFeatureReady, setSkillsFeatureReady] = useState(false);
const gatewayBannerState = getSkillsGatewayBannerState(gatewayStatus, skillsFeatureReady);
const [showGatewayBanner, setShowGatewayBanner] = useState(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (!isGatewayRunning) {
if (gatewayBannerState === 'none') {
timer = setTimeout(() => {
setShowGatewayWarning(true);
}, 1500);
setShowGatewayBanner(false);
}, 0);
} else {
timer = setTimeout(() => {
setShowGatewayWarning(false);
}, 0);
setShowGatewayBanner(true);
}, 1500);
}
return () => clearTimeout(timer);
}, [isGatewayRunning]);
}, [gatewayBannerState]);
useEffect(() => {
if (isGatewayRunning) {
fetchSkills();
if (!gatewayRunning) {
setSkillsFeatureReady(false);
return;
}
}, [fetchSkills, isGatewayRunning]);
setSkillsFeatureReady(gatewayReportedReady);
let cancelled = false;
let retryTimer: ReturnType<typeof setInterval> | null = null;
const attemptFetch = async () => {
const ok = await fetchSkills();
if (cancelled || !ok) return;
setSkillsFeatureReady(true);
if (retryTimer) {
clearInterval(retryTimer);
retryTimer = null;
}
};
void attemptFetch();
if (!gatewayReportedReady) {
retryTimer = setInterval(() => {
void attemptFetch();
}, 5_000);
}
return () => {
cancelled = true;
if (retryTimer) {
clearInterval(retryTimer);
}
};
}, [fetchSkills, gatewayReportedReady, gatewayRunning, gatewayRuntimeKey]);
const safeSkills = Array.isArray(skills) ? skills : [];
const filteredSkills = safeSkills.filter((skill) => {
@@ -443,7 +499,7 @@ export function Skills() {
}
return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div data-testid="skills-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">
{/* Header */}
@@ -470,12 +526,31 @@ export function Skills() {
</div>
</div>
{/* Gateway Warning */}
{showGatewayWarning && (
<div className="mb-6 p-4 rounded-xl border border-yellow-500/50 bg-yellow-500/10 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
<span className="text-yellow-700 dark:text-yellow-400 text-sm font-medium">
{t('gatewayWarning')}
{/* Gateway Status Banner */}
{showGatewayBanner && gatewayBannerState !== 'none' && (
<div
data-testid="skills-gateway-banner"
data-state={gatewayBannerState}
className={cn(
"mb-6 p-4 rounded-xl border flex items-center gap-3",
gatewayBannerState === 'starting'
? "border-blue-500/40 bg-blue-500/10"
: "border-yellow-500/50 bg-yellow-500/10",
)}
>
<AlertCircle className={cn(
"h-5 w-5",
gatewayBannerState === 'starting'
? "text-blue-600 dark:text-blue-400"
: "text-yellow-600 dark:text-yellow-400",
)} />
<span className={cn(
"text-sm font-medium",
gatewayBannerState === 'starting'
? "text-blue-700 dark:text-blue-400"
: "text-yellow-700 dark:text-yellow-400",
)}>
{gatewayBannerState === 'starting' ? t('gatewayStarting') : t('gatewayWarning')}
</span>
</div>
)}
@@ -555,8 +630,10 @@ export function Skills() {
<Button
variant="outline"
size="icon"
onClick={fetchSkills}
disabled={!isGatewayRunning}
onClick={() => {
void fetchSkills();
}}
disabled={!gatewayRunning}
className="h-8 w-8 ml-1 rounded-md border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-muted-foreground hover:text-foreground"
title={t('refresh')}
>

View File

@@ -65,19 +65,14 @@ export const useArtifactPanel = create<ArtifactPanelState>()(
focusedFile: null,
widthPct: ARTIFACT_PANEL_DEFAULT_WIDTH,
setTab: (tab) => {
// The browser tab has its own internal selection (workspace tree)
// and should not inherit a chat-side focused file. The changes
// and preview tabs share `focusedFile`.
if (tab === 'browser') {
set({ tab, focusedFile: null });
} else {
set({ tab, focusedFile: get().focusedFile });
}
// The browser tab has its own internal workspace-tree selection, so
// keep the chat-side focused file available for preview/changes.
set({ tab, focusedFile: get().focusedFile });
},
setFocusedFile: (focusedFile) => set({ focusedFile }),
openChanges: (file = null) => set({ open: true, tab: 'changes', focusedFile: file ?? null }),
openPreview: (file = null) => set({ open: true, tab: 'preview', focusedFile: file ?? null }),
openBrowser: () => set({ open: true, tab: 'browser', focusedFile: null }),
openBrowser: () => set({ open: true, tab: 'browser', focusedFile: get().focusedFile }),
toggle: () => set((s) => ({ open: !s.open })),
close: () => set({ open: false, focusedFile: null }),
setWidthPct: (pct) => set({ widthPct: clampWidth(pct) }),

View File

@@ -67,7 +67,7 @@ interface SkillsState {
error: string | null;
// Actions
fetchSkills: () => Promise<void>;
fetchSkills: () => Promise<boolean>;
searchSkills: (query: string) => Promise<void>;
installSkill: (slug: string, version?: string) => Promise<void>;
uninstallSkill: (slug: string) => Promise<void>;
@@ -96,12 +96,22 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
const gatewayDataPromise = useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
const clawhubResultPromise = hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
const configResultPromise = hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
const [gatewayData, clawhubResult, configResult] = await Promise.all([
const [gatewayDataResult, clawhubResult, configResult] = await Promise.allSettled([
gatewayDataPromise,
clawhubResultPromise,
configResultPromise,
]);
if (gatewayDataResult.status !== 'fulfilled') {
throw gatewayDataResult.reason;
}
const gatewayData = gatewayDataResult.value;
const clawhubData = clawhubResult.status === 'fulfilled' ? clawhubResult.value : undefined;
const configData = configResult.status === 'fulfilled' && configResult.value && typeof configResult.value === 'object'
? configResult.value
: {};
let combinedSkills: Skill[] = [];
const currentSkills = get().skills;
@@ -109,7 +119,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
if (gatewayData.skills) {
combinedSkills = gatewayData.skills.map((s: GatewaySkillStatus) => {
// Merge with direct config if available
const directConfig = configResult[s.skillKey] || {};
const directConfig = configData[s.skillKey] || {};
return {
id: s.skillKey,
@@ -137,8 +147,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
// Merge with ClawHub results
if (clawhubResult.success && clawhubResult.results) {
clawhubResult.results.forEach((cs: ClawHubListResult) => {
if (clawhubData?.success && clawhubData.results) {
clawhubData.results.forEach((cs: ClawHubListResult) => {
const existing = combinedSkills.find(s => s.id === cs.slug);
if (existing) {
if (!existing.baseDir && cs.baseDir) {
@@ -149,7 +159,7 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
}
return;
}
const directConfig = configResult[cs.slug] || {};
const directConfig = configData[cs.slug] || {};
combinedSkills.push({
id: cs.slug,
slug: cs.slug,
@@ -168,13 +178,30 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
});
}
set({ skills: combinedSkills, loading: false });
const partialError = clawhubResult.status === 'rejected'
? clawhubResult.reason
: configResult.status === 'rejected'
? configResult.reason
: clawhubData?.success === false
? new Error(clawhubData.error || 'Failed to fetch marketplace skills')
: null;
if (partialError) {
const appError = normalizeAppError(partialError, { module: 'skills', operation: 'fetch' });
const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch');
set({ skills: combinedSkills, loading: false, error: errorKey ?? appError.message });
} else {
set({ skills: combinedSkills, loading: false, error: null });
}
return true;
} catch (error) {
console.error('Failed to fetch skills:', error);
const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' });
const errorKey = mapErrorCodeToSkillErrorKey(appError.code, 'fetch');
// Preserve previous skills on error (stale-while-revalidate).
set((prev) => ({ loading: false, error: errorKey ?? appError.message, skills: prev.skills }));
return false;
}
},

View File

@@ -41,6 +41,31 @@ const history = [
},
];
const attachedFileHistory = [
{
role: 'user',
id: 'user-attached-1',
content: [{ type: 'text', text: '查看这个技能文件' }],
timestamp: Date.now(),
},
{
role: 'assistant',
id: 'assistant-attached-1',
content: [{ type: 'text', text: '这是文件。' }],
_attachedFiles: [
{
fileName: 'SKILL.md',
mimeType: 'text/markdown',
fileSize: 128,
preview: null,
filePath: '/workspace/skills/open-xueqiu/SKILL.md',
source: 'tool-result',
},
],
timestamp: Date.now(),
},
];
test.describe('ClawX chat file changes', () => {
test('shows line stats on generated file cards', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
@@ -130,4 +155,75 @@ test.describe('ClawX chat file changes', () => {
await closeElectronApp(app);
}
});
test('keeps an attached file selected after switching through workspace', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
success: true,
result: { messages: attachedFileHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
success: true,
result: { messages: attachedFileHistory },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{ id: 'main', name: 'main', workspace: '/workspace' }],
},
},
},
},
});
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
throw error;
}
}
await expect(page.getByTestId('main-layout')).toBeVisible();
const skillFileCard = page.locator('[title="Open file"]').filter({ hasText: 'SKILL.md' }).first();
await expect(skillFileCard).toBeVisible({ timeout: 30_000 });
await skillFileCard.click();
const sidePanel = page.getByTestId('artifact-panel');
await expect(sidePanel.getByRole('heading', { name: 'SKILL.md' })).toBeVisible({ timeout: 30_000 });
await sidePanel.getByTestId('artifact-panel-tab-browser').click();
await sidePanel.getByTestId('artifact-panel-tab-preview').click();
await expect(sidePanel.getByRole('heading', { name: 'SKILL.md' })).toBeVisible();
await expect(sidePanel.getByText('尚未选择文件')).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -0,0 +1,49 @@
import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron';
test.describe('Skills page gateway readiness', () => {
test('clears stale startup banner once runtime skills RPC succeeds', async ({ electronApp, page }) => {
await completeSetup(page);
await installIpcMocks(electronApp, {
gatewayRpc: {
'["skills.status",null]': { success: false, error: 'Gateway not connected' },
},
});
await page.getByTestId('sidebar-nav-skills').click();
await expect(page.getByTestId('skills-page')).toBeVisible();
await expect(page.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'stopped', { timeout: 3_500 });
await electronApp.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win?.webContents.send('gateway:status-changed', {
state: 'running',
port: 18789,
pid: 12345,
connectedAt: 1,
gatewayReady: false,
});
});
await expect(page.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'starting', { timeout: 3_500 });
await installIpcMocks(electronApp, {
gatewayRpc: {
'["skills.status",null]': { success: true, result: { skills: [] } },
},
});
await electronApp.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win?.webContents.send('gateway:status-changed', {
state: 'running',
port: 18789,
pid: 12345,
connectedAt: 2,
gatewayReady: false,
});
});
await expect(page.getByTestId('skills-gateway-banner')).toHaveCount(0, { timeout: 2_000 });
});
});

View File

@@ -0,0 +1,46 @@
import type { ElectronApplication } from '@playwright/test';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
async function getZoomLevel(app: ElectronApplication): Promise<number> {
return await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win?.webContents.getZoomLevel() ?? 0;
});
}
async function sendZoomShortcut(app: ElectronApplication, action: 'in' | 'out'): Promise<void> {
await app.evaluate(({ BrowserWindow }, zoomAction) => {
const win = BrowserWindow.getAllWindows()[0];
const contents = win?.webContents;
if (!contents) return;
const input = zoomAction === 'out'
? { key: '-', code: 'Minus', control: true, meta: false, alt: false }
: { key: '=', code: 'Equal', control: true, meta: false, alt: false };
contents.emit('before-input-event', { preventDefault() {} }, input);
}, action);
}
test.describe('ClawX window zoom shortcuts', () => {
test('can zoom back in after zooming out with keyboard shortcuts', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('main-layout')).toBeVisible();
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.webContents.setZoomLevel(0);
});
await sendZoomShortcut(app, 'out');
await expect.poll(async () => await getZoomLevel(app)).toBe(-1);
await sendZoomShortcut(app, 'in');
await expect.poll(async () => await getZoomLevel(app)).toBe(0);
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ArtifactPanel } from '@/components/file-preview/ArtifactPanel';
import { ARTIFACT_PANEL_DEFAULT_WIDTH, useArtifactPanel } from '@/stores/artifact-panel';
import type { GeneratedFile } from '@/lib/generated-files';
@@ -73,4 +73,35 @@ describe('ArtifactPanel', () => {
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('~/.openclaw/skills/open-baidu/SKILL.md');
expect(screen.queryByText('test_example.py')).not.toBeInTheDocument();
});
it('keeps the selected preview file after visiting the workspace tab', () => {
useArtifactPanel.setState({
open: true,
tab: 'preview',
focusedFile: {
filePath: '~/.openclaw/skills/open-xueqiu/SKILL.md',
fileName: 'SKILL.md',
ext: '.md',
mimeType: 'text/markdown',
contentType: 'document',
},
widthPct: ARTIFACT_PANEL_DEFAULT_WIDTH,
});
render(
<ArtifactPanel
files={[makeGeneratedFile()]}
agent={null}
/>,
);
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('SKILL.md');
fireEvent.click(screen.getByRole('button', { name: '工作空间' }));
expect(screen.getByTestId('workspace-browser')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '预览' }));
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('SKILL.md');
expect(screen.queryByText('尚未选择文件')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Skills } from '@/pages/Skills';
const fetchSkillsMock = vi.fn();
const enableSkillMock = vi.fn();
const disableSkillMock = vi.fn();
const searchSkillsMock = vi.fn();
const installSkillMock = vi.fn();
const uninstallSkillMock = vi.fn();
const invokeIpcMock = vi.fn();
const { gatewayState } = vi.hoisted(() => ({
gatewayState: {
status: { state: 'running', port: 18789, gatewayReady: true } as {
state: string;
port: number;
gatewayReady?: boolean;
},
},
}));
vi.mock('@/stores/skills', () => ({
useSkillsStore: () => ({
skills: [],
loading: false,
error: null,
fetchSkills: fetchSkillsMock,
enableSkill: enableSkillMock,
disableSkill: disableSkillMock,
searchResults: [],
searchSkills: searchSkillsMock,
installSkill: installSkillMock,
uninstallSkill: uninstallSkillMock,
searching: false,
searchError: null,
installing: {},
}),
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
}));
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: vi.fn(),
}));
vi.mock('@/lib/telemetry', () => ({
trackUiEvent: vi.fn(),
}));
vi.mock('@/extensions/registry', () => ({
rendererExtensionRegistry: {
getSkillDetailMetaComponents: () => [],
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}));
describe('Skills page gateway readiness', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
gatewayState.status = { state: 'running', port: 18789, gatewayReady: true };
invokeIpcMock.mockResolvedValue('/tmp/.openclaw/skills');
fetchSkillsMock.mockResolvedValue(true);
});
afterEach(() => {
vi.useRealTimers();
});
it('keeps loading skills while gatewayReady is false and hides the banner once skills fetch succeeds', async () => {
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
render(<Skills />);
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1_600);
});
expect(fetchSkillsMock).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('skills-gateway-banner')).not.toBeInTheDocument();
});
it('shows a starting banner while the running gateway still cannot serve skills data', async () => {
fetchSkillsMock.mockResolvedValue(false);
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
render(<Skills />);
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(1_600);
});
expect(fetchSkillsMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'starting');
});
it('shows stopped banner copy when the gateway is stopped', async () => {
gatewayState.status = { state: 'stopped', port: 18789 };
render(<Skills />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1_600);
});
expect(screen.getByTestId('skills-gateway-banner')).toHaveAttribute('data-state', 'stopped');
expect(fetchSkillsMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { getZoomShortcutAction } from '@electron/main/zoom-shortcuts';
function input(overrides: Partial<Electron.Input>): Electron.Input {
return {
type: 'keyDown',
key: '',
code: '',
isAutoRepeat: false,
shift: false,
control: false,
alt: false,
meta: false,
...overrides,
};
}
describe('zoom shortcuts', () => {
it('recognizes zoom in from plus and equal keys', () => {
expect(getZoomShortcutAction(input({ control: true, key: '+', code: 'Equal', shift: true }))).toBe('in');
expect(getZoomShortcutAction(input({ control: true, key: '=', code: 'Equal' }))).toBe('in');
expect(getZoomShortcutAction(input({ control: true, key: '+', code: 'NumpadAdd' }))).toBe('in');
});
it('recognizes zoom out and reset shortcuts', () => {
expect(getZoomShortcutAction(input({ control: true, key: '-', code: 'Minus' }))).toBe('out');
expect(getZoomShortcutAction(input({ control: true, key: '-', code: 'NumpadSubtract' }))).toBe('out');
expect(getZoomShortcutAction(input({ control: true, key: '0', code: 'Digit0' }))).toBe('reset');
});
it('requires a command modifier without alt', () => {
expect(getZoomShortcutAction(input({ key: '+', code: 'Equal' }))).toBeNull();
expect(getZoomShortcutAction(input({ control: true, alt: true, key: '+', code: 'Equal' }))).toBeNull();
expect(getZoomShortcutAction(input({ meta: true, key: '+', code: 'Equal' }))).toBe('in');
});
});