fix: Windows zoom shortcuts and keep chat preview file after workspace tab (#982)
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
47
electron/main/zoom-shortcuts.ts
Normal file
47
electron/main/zoom-shortcuts.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"refresh": "更新",
|
||||
"openFolder": "スキルフォルダを開く",
|
||||
"gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。",
|
||||
"gatewayStarting": "ゲートウェイを起動中です。準備ができしだいスキルを自動で読み込みます。",
|
||||
"tabs": {
|
||||
"installed": "インストール済み",
|
||||
"marketplace": "マーケットプレイス"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"refresh": "Обновить",
|
||||
"openFolder": "Открыть папку навыков",
|
||||
"gatewayWarning": "Шлюз не запущен. Навыки не могут быть загружены без активного шлюза.",
|
||||
"gatewayStarting": "Шлюз запускается. Навыки загрузятся автоматически, когда он будет готов.",
|
||||
"tabs": {
|
||||
"installed": "Установленные",
|
||||
"marketplace": "Маркетплейс"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"refresh": "刷新",
|
||||
"openFolder": "打开技能文件夹",
|
||||
"gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。",
|
||||
"gatewayStarting": "网关正在启动。待其就绪后会自动加载技能。",
|
||||
"tabs": {
|
||||
"installed": "已安装",
|
||||
"marketplace": "市场"
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
49
tests/e2e/skills-gateway-readiness.spec.ts
Normal file
49
tests/e2e/skills-gateway-readiness.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
46
tests/e2e/zoom-shortcuts.spec.ts
Normal file
46
tests/e2e/zoom-shortcuts.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
129
tests/unit/skills-page-gateway-readiness.test.tsx
Normal file
129
tests/unit/skills-page-gateway-readiness.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
tests/unit/zoom-shortcuts.test.ts
Normal file
36
tests/unit/zoom-shortcuts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user