mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-28 01:25:54 +00:00
feat(report-bug): add diagnostics export and email report flow
Replace the top-bar support dropdown with a report bug dialog that packages diagnostics, prepares bug metadata, and opens a prefilled support email while keeping cancel as a no-op. This also updates docs/locales and adds a regression test for canceled saves. Made-with: Cursor
This commit is contained in:
parent
92dbb06b69
commit
175e62a72e
12 changed files with 630 additions and 92 deletions
|
|
@ -10,23 +10,23 @@ width="100%"
|
|||
height="auto"
|
||||
/>
|
||||
|
||||
### Step 1: Access the Bug Report Feature
|
||||
### Step 1: Open Report a bug
|
||||
|
||||
1. Locate the **Bug Report** button in the top section of the desktop interface, positioned to the right of your project name
|
||||
1. Click the **Bug Report** button to initiate the reporting process
|
||||
1. In the top bar, click the **Support** (help) icon
|
||||
1. Choose **Report a bug**
|
||||
|
||||
### Step 2: Download Log Files
|
||||
### Step 2: Describe the issue and save diagnostics
|
||||
|
||||
- Upon clicking the Bug Report button, log files will be automatically downloaded to your device
|
||||
- These log files contain technical information that helps our development team diagnose issues more effectively
|
||||
1. Enter what went wrong. Optionally add **steps to reproduce**
|
||||
1. Click **Save diagnostics and open email**
|
||||
1. Choose where to save the **diagnostics ZIP** (it includes app logs and a `bug_report.txt` file)
|
||||
|
||||
### Step 3: Complete the Bug Report Form
|
||||
Your default email app opens addressed to **info@eigent.ai** with a pre-filled message.
|
||||
|
||||
- A bug report web form will automatically open in your default browser
|
||||
- Please provide the following information:
|
||||
- **Bug Description**: Write a clear description of the issue you encountered
|
||||
- **Screenshot Upload**: Attach relevant screenshots that demonstrate the problem
|
||||
- **Log File Upload**: Upload the log files that were downloaded in Step 2
|
||||
### Step 3: Send the email
|
||||
|
||||
1. **Attach the ZIP** you just saved to the message (the mail app cannot add this automatically)
|
||||
1. Add screenshots or other files if they help, then send
|
||||
|
||||
### Step 4: Join Our Community for Real-time Support
|
||||
|
||||
|
|
@ -47,9 +47,11 @@ Developers and technical users are welcome to report issues directly through our
|
|||
- **Repository URL**: https://github.com/eigent-ai/eigent
|
||||
- Submit detailed issues with reproduction steps
|
||||
|
||||
**Optional:** In the same **Report a bug** dialog, use **Download logs** to save a single log file (without the full diagnostics ZIP).
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always include log files when reporting bugs for faster resolution
|
||||
- Always include the diagnostics ZIP (or log export) when reporting bugs for faster resolution
|
||||
- Provide as much detail as possible in your bug description
|
||||
- Screenshots help our team understand visual issues more quickly
|
||||
- Our community channels provide the fastest response times for urgent issues
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ import {
|
|||
removeEnvKey,
|
||||
updateEnvBlock,
|
||||
} from './utils/envUtil';
|
||||
import { zipFolder } from './utils/log';
|
||||
import { createDiagnosticsZip, zipFolder } from './utils/log';
|
||||
import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig';
|
||||
import {
|
||||
checkVenvExistsForPreCheck,
|
||||
|
|
@ -1111,6 +1111,100 @@ function registerIpcHandlers() {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-diagnostics-info', async () => {
|
||||
return {
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'export-diagnostics-zip',
|
||||
async (
|
||||
_event,
|
||||
payload: { description: string; steps?: string } | undefined
|
||||
) => {
|
||||
try {
|
||||
const description =
|
||||
typeof payload?.description === 'string'
|
||||
? payload.description.trim()
|
||||
: '';
|
||||
if (!description) {
|
||||
return { success: false, error: 'Description is required' };
|
||||
}
|
||||
const steps =
|
||||
typeof payload?.steps === 'string' ? payload.steps.trim() : '';
|
||||
|
||||
const logFiles: { src: string; destName: string }[] = [];
|
||||
if (fs.existsSync(logPath)) {
|
||||
logFiles.push({ src: logPath, destName: 'electron-main.log' });
|
||||
}
|
||||
const backupResolved = getBackupLogPath();
|
||||
if (
|
||||
fs.existsSync(backupResolved) &&
|
||||
path.resolve(backupResolved) !== path.resolve(logPath)
|
||||
) {
|
||||
logFiles.push({
|
||||
src: backupResolved,
|
||||
destName: 'electron-userdata-logs.log',
|
||||
});
|
||||
}
|
||||
if (logFiles.length === 0) {
|
||||
return { success: false, error: 'no log file' };
|
||||
}
|
||||
|
||||
const appVersion = app.getVersion();
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const bugReportText = [
|
||||
'Eigent bug report',
|
||||
'=================',
|
||||
'',
|
||||
`App version: ${appVersion}`,
|
||||
`OS: ${platform} (${arch})`,
|
||||
'',
|
||||
'Description',
|
||||
'-----------',
|
||||
description,
|
||||
'',
|
||||
...(steps
|
||||
? ['Steps to reproduce', '-------------------', steps, '']
|
||||
: []),
|
||||
].join('\n');
|
||||
|
||||
const defaultFileName = `eigent-diagnostics-${appVersion}-${Date.now()}.zip`;
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
title: 'Save diagnostics',
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
|
||||
});
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
|
||||
await createDiagnosticsZip(filePath, bugReportText, logFiles);
|
||||
return { success: true, savedPath: filePath };
|
||||
} catch (error: any) {
|
||||
log.error('export-diagnostics-zip failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('open-mailto', async (_event, url: string) => {
|
||||
try {
|
||||
if (typeof url !== 'string' || !url.startsWith('mailto:')) {
|
||||
return { success: false, error: 'Invalid mailto URL' };
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'upload-log',
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
// @ts-ignore
|
||||
import archiver from 'archiver';
|
||||
import log from 'electron-log';
|
||||
|
|
@ -37,3 +41,42 @@ export function zipFolder(
|
|||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
export type DiagnosticsLogFile = { src: string; destName: string };
|
||||
|
||||
/**
|
||||
* Stages log files and bug_report.txt into a temp directory, zips to outputZipPath, then removes the staging dir.
|
||||
*/
|
||||
export async function createDiagnosticsZip(
|
||||
outputZipPath: string,
|
||||
bugReportText: string,
|
||||
logFiles: DiagnosticsLogFile[]
|
||||
): Promise<void> {
|
||||
if (logFiles.length === 0) {
|
||||
throw new Error('no log files to include');
|
||||
}
|
||||
const id = randomBytes(8).toString('hex');
|
||||
const staging = path.join(os.tmpdir(), `eigent-diagnostics-${id}`);
|
||||
await fsp.mkdir(staging, { recursive: true });
|
||||
try {
|
||||
for (const f of logFiles) {
|
||||
if (!fs.existsSync(f.src)) {
|
||||
log.warn(`[diagnostics] skip missing log: ${f.src}`);
|
||||
continue;
|
||||
}
|
||||
await fsp.copyFile(f.src, path.join(staging, f.destName));
|
||||
}
|
||||
await fsp.writeFile(
|
||||
path.join(staging, 'bug_report.txt'),
|
||||
bugReportText,
|
||||
'utf-8'
|
||||
);
|
||||
const entries = await fsp.readdir(staging);
|
||||
if (entries.length === 0) {
|
||||
throw new Error('no log files to include');
|
||||
}
|
||||
await zipFolder(staging, outputZipPath);
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
webviewDestroy: (webviewId: string) =>
|
||||
ipcRenderer.invoke('webview-destroy', webviewId),
|
||||
exportLog: () => ipcRenderer.invoke('export-log'),
|
||||
getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'),
|
||||
exportDiagnosticsZip: (payload: { description: string; steps?: string }) =>
|
||||
ipcRenderer.invoke('export-diagnostics-zip', payload),
|
||||
openMailto: (url: string) => ipcRenderer.invoke('open-mailto', url),
|
||||
uploadLog: (email: string, taskId: string, baseUrl: string, token: string) =>
|
||||
ipcRenderer.invoke('upload-log', email, taskId, baseUrl, token),
|
||||
// mcp
|
||||
|
|
|
|||
|
|
@ -9,3 +9,7 @@ src/components/ui/WordCarousel/WordCarousel.tsx
|
|||
src/components/Terminal/index.tsx
|
||||
#
|
||||
# Install progress bar (src/components/ui/progress-install.tsx) and similar: use CSS vars only — no entry needed.
|
||||
#
|
||||
# Electron shell surfaces use fixed native colors in preload HTML and BrowserWindow options.
|
||||
electron/main/index.ts
|
||||
electron/preload/index.ts
|
||||
|
|
|
|||
293
src/components/Dialog/ReportBugDialog.tsx
Normal file
293
src/components/Dialog/ReportBugDialog.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useHost } from '@/host';
|
||||
import { Download } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const INFO_EMAIL = 'info@eigent.ai';
|
||||
|
||||
function buildMailtoUrl(
|
||||
subject: string,
|
||||
body: string
|
||||
): { url: string; truncated: boolean } {
|
||||
const maxLen = 1800;
|
||||
const tail = '\n\n[…]';
|
||||
let b = body;
|
||||
let truncated = false;
|
||||
if (b.length > maxLen) {
|
||||
b = b.slice(0, maxLen - tail.length) + tail;
|
||||
truncated = true;
|
||||
}
|
||||
const url = `mailto:${INFO_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(b)}`;
|
||||
return { url, truncated };
|
||||
}
|
||||
|
||||
/** Matches `getDiagnosticsInfo` in preload / `ElectronAPI` */
|
||||
type DiagnosticsInfo = {
|
||||
version: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
};
|
||||
|
||||
interface ReportBugDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ReportBugDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ReportBugDialogProps) {
|
||||
const host = useHost();
|
||||
const { t } = useTranslation();
|
||||
const [description, setDescription] = useState('');
|
||||
const [steps, setSteps] = useState('');
|
||||
const [meta, setMeta] = useState<DiagnosticsInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [downloadingLog, setDownloadingLog] = useState(false);
|
||||
|
||||
const handleDownloadLog = useCallback(async () => {
|
||||
const exportLog = host?.electronAPI?.exportLog;
|
||||
if (!exportLog) {
|
||||
toast.error(t('layout.general-error'));
|
||||
return;
|
||||
}
|
||||
setDownloadingLog(true);
|
||||
try {
|
||||
const response = await exportLog();
|
||||
if (!response?.success) {
|
||||
if (response?.error) {
|
||||
toast.error(response.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (response.savedPath) {
|
||||
toast.success(`${t('layout.log-saved')} ${response.savedPath}`);
|
||||
return;
|
||||
}
|
||||
if (response.data === 'log file is empty') {
|
||||
toast.info(t('layout.log-file-empty'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
|
||||
} finally {
|
||||
setDownloadingLog(false);
|
||||
}
|
||||
}, [host?.electronAPI, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const api = host?.electronAPI;
|
||||
if (!api?.getDiagnosticsInfo) {
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.getDiagnosticsInfo()
|
||||
.then((info: DiagnosticsInfo) => {
|
||||
if (info?.version) {
|
||||
setMeta({
|
||||
version: info.version,
|
||||
platform: info.platform,
|
||||
arch: info.arch,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMeta(null);
|
||||
});
|
||||
}, [open, host?.electronAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDescription('');
|
||||
setSteps('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
const api = host?.electronAPI;
|
||||
if (!api?.exportDiagnosticsZip || !api?.openMailto) {
|
||||
toast.error(t('layout.general-error'));
|
||||
return;
|
||||
}
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('layout.report-bug-description-required'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await api.exportDiagnosticsZip({
|
||||
description: trimmed,
|
||||
steps: steps.trim() || undefined,
|
||||
});
|
||||
if (!response?.success) {
|
||||
if (response?.error === '') {
|
||||
return;
|
||||
}
|
||||
if (response?.error) {
|
||||
toast.error(response.error);
|
||||
} else {
|
||||
toast.error(t('layout.general-error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!response.savedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = t('layout.report-bug-mail-subject');
|
||||
const v = meta?.version ?? '—';
|
||||
const p = meta?.platform ?? '—';
|
||||
const a = meta?.arch ?? '—';
|
||||
const body = [
|
||||
t('layout.report-bug-mail-body-intro'),
|
||||
'',
|
||||
t('layout.report-bug-mail-body-path', { path: response.savedPath }),
|
||||
'',
|
||||
'—',
|
||||
t('layout.report-bug-mail-body-meta', { version: v, os: p, arch: a }),
|
||||
'',
|
||||
t('layout.report-bug-mail-body-desc'),
|
||||
trimmed,
|
||||
'',
|
||||
...(steps.trim()
|
||||
? [t('layout.report-bug-mail-body-steps'), steps.trim(), '']
|
||||
: []),
|
||||
].join('\n');
|
||||
|
||||
const { url, truncated } = buildMailtoUrl(subject, body);
|
||||
if (truncated) {
|
||||
toast.info(t('layout.report-bug-mail-body-truncated'));
|
||||
}
|
||||
|
||||
const mail = await api.openMailto(url);
|
||||
if (!mail?.success) {
|
||||
if (mail?.error) {
|
||||
toast.error(mail.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
toast.success(t('layout.report-bug-diagnostics-saved'));
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [host?.electronAPI, description, steps, meta, onOpenChange, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
size="md"
|
||||
className="gap-0 !rounded-xl border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px] border"
|
||||
>
|
||||
<div className="bg-ds-bg-neutral-strong-default rounded-t-xl pl-md pr-12 pt-md pb-2 w-full text-left">
|
||||
<DialogTitle className="m-0 text-body-md font-bold text-ds-text-neutral-default-default block w-full text-left">
|
||||
{t('layout.report-bug-dialog-title')}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<div className="gap-md bg-ds-bg-neutral-strong-default px-md pt-2 pb-md flex max-h-[min(70vh,520px)] flex-col text-left">
|
||||
{meta && (
|
||||
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
|
||||
{t('layout.report-bug-meta', {
|
||||
version: meta.version,
|
||||
os: meta.platform,
|
||||
arch: meta.arch,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
|
||||
{t('layout.report-bug-footer-hint')}
|
||||
</p>
|
||||
<label
|
||||
className="text-body-sm font-medium text-ds-text-neutral-default-default"
|
||||
htmlFor="report-bug-description"
|
||||
>
|
||||
{t('layout.report-bug-field-description')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="report-bug-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('layout.report-bug-field-description-placeholder')}
|
||||
className="min-h-[88px] resize-y"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<label
|
||||
className="text-body-sm font-medium text-ds-text-neutral-default-default"
|
||||
htmlFor="report-bug-steps"
|
||||
>
|
||||
{t('layout.report-bug-field-steps')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="report-bug-steps"
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(e.target.value)}
|
||||
placeholder={t('layout.report-bug-field-steps-placeholder')}
|
||||
className="min-h-[72px] resize-y"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="text"
|
||||
onClick={() => void handleDownloadLog()}
|
||||
disabled={submitting || downloadingLog}
|
||||
>
|
||||
<Download className="h-4 w-4 shrink-0" aria-hidden />
|
||||
{t('layout.download-logs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="!rounded-b-xl p-md gap-sm sm:!flex-col flex !flex-col !border-0 !border-t-0 bg-transparent shadow-none">
|
||||
<div className="gap-sm flex w-full flex-row justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('layout.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => void onSubmit()}
|
||||
disabled={submitting || !description.trim()}
|
||||
>
|
||||
{t('layout.report-bug-submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,14 +19,9 @@ import eigentAppIconBlack from '@/assets/logo/icon_black.svg';
|
|||
import eigentAppIconWhite from '@/assets/logo/icon_white.svg';
|
||||
import logoBlack from '@/assets/logo/logo_black.png';
|
||||
import logoWhite from '@/assets/logo/logo_white.png';
|
||||
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
|
||||
import NotificationPanel from '@/components/Notification';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useHost } from '@/host';
|
||||
|
|
@ -60,8 +55,6 @@ import {
|
|||
} from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const SUPPORT_EMAIL = 'support@eigent.ai';
|
||||
|
||||
/** Tracks linear in-app history so back/forward buttons can enable/disable like a browser. */
|
||||
function useStackNavigationBounds() {
|
||||
const location = useLocation();
|
||||
|
|
@ -127,6 +120,7 @@ function HeaderWin() {
|
|||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { canGoBack, canGoForward } = useStackNavigationBounds();
|
||||
const [reportBugOpen, setReportBugOpen] = useState(false);
|
||||
//Get Chatstore for the active project's task
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const { chatPanelPosition, setChatPanelPosition } = usePageTabStore();
|
||||
|
|
@ -184,41 +178,6 @@ function HeaderWin() {
|
|||
share(taskId);
|
||||
};
|
||||
|
||||
const handleDownloadLog = async () => {
|
||||
try {
|
||||
const exportLog = host?.electronAPI?.exportLog;
|
||||
if (!exportLog) {
|
||||
toast.error(t('layout.general-error'));
|
||||
return;
|
||||
}
|
||||
const response = await exportLog();
|
||||
if (!response?.success) {
|
||||
if (response?.error) {
|
||||
toast.error(response.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (response.savedPath) {
|
||||
toast.success(`${t('layout.log-saved')} ${response.savedPath}`);
|
||||
return;
|
||||
}
|
||||
if (response.data === 'log file is empty') {
|
||||
toast.info(t('layout.log-file-empty'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('layout.general-error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySupportEmail = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SUPPORT_EMAIL);
|
||||
toast.success(t('layout.email-copied'));
|
||||
} catch {
|
||||
toast.error(t('layout.copy-failed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!chatStore) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
|
@ -403,42 +362,22 @@ function HeaderWin() {
|
|||
<Bell className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<DropdownMenu>
|
||||
<TooltipSimple
|
||||
content={t('layout.support')}
|
||||
side="bottom"
|
||||
align="end"
|
||||
<TooltipSimple
|
||||
content={t('layout.support')}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="no-drag rounded-full"
|
||||
aria-label={t('layout.support')}
|
||||
onClick={() => setReportBugOpen(true)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="no-drag rounded-full"
|
||||
aria-label={t('layout.support')}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<CircleHelp className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipSimple>
|
||||
<DropdownMenuContent side="bottom" align="end" sideOffset={6}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void handleDownloadLog();
|
||||
}}
|
||||
>
|
||||
{t('layout.download-logs')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void handleCopySupportEmail();
|
||||
}}
|
||||
>
|
||||
{t('layout.copy-email')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CircleHelp className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
content={t('layout.refer-friends')}
|
||||
side="bottom"
|
||||
|
|
@ -506,6 +445,7 @@ function HeaderWin() {
|
|||
open={notificationPanelOpen}
|
||||
onOpenChange={setNotificationPanelOpen}
|
||||
/>
|
||||
<ReportBugDialog open={reportBugOpen} onOpenChange={setReportBugOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
{
|
||||
"report-bug": "Report a bug",
|
||||
"report-bug-dialog-title": "Report a bug",
|
||||
"report-bug-footer-hint": "We will save a ZIP of app logs, then open your email app. Attach the saved ZIP to the message before sending.",
|
||||
"report-bug-field-description": "What went wrong?",
|
||||
"report-bug-field-description-placeholder": "Describe the problem…",
|
||||
"report-bug-field-steps": "Steps to reproduce (optional)",
|
||||
"report-bug-field-steps-placeholder": "1. … 2. …",
|
||||
"report-bug-meta": "Version {{version}} · {{os}} ({{arch}})",
|
||||
"report-bug-description-required": "Please describe what went wrong.",
|
||||
"report-bug-submit": "Save diagnostics and open email",
|
||||
"report-bug-diagnostics-saved": "Diagnostics saved. Attach the ZIP in your email, then send.",
|
||||
"report-bug-mail-subject": "[Eigent] Bug report",
|
||||
"report-bug-mail-body-intro": "Please attach the diagnostics ZIP file you just saved to this message.",
|
||||
"report-bug-mail-body-path": "ZIP saved to: {{path}}",
|
||||
"report-bug-mail-body-meta": "App version: {{version}} | OS: {{os}} ({{arch}})",
|
||||
"report-bug-mail-body-desc": "Description:",
|
||||
"report-bug-mail-body-steps": "Steps to reproduce:",
|
||||
"report-bug-mail-body-truncated": "The email body was shortened because of length limits. See bug_report.txt inside the ZIP for the full text.",
|
||||
"refer-friends": "Refer friends",
|
||||
"retry": "Retry",
|
||||
"please-enter-email-address": "Please enter email address",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
{
|
||||
"report-bug": "报告 bug",
|
||||
"report-bug-dialog-title": "报告 bug",
|
||||
"report-bug-footer-hint": "我们会将应用日志打包为 ZIP 并打开您的邮件客户端。发送前请将该 ZIP 作为附件。",
|
||||
"report-bug-field-description": "出现了什么问题?",
|
||||
"report-bug-field-description-placeholder": "请描述问题…",
|
||||
"report-bug-field-steps": "复现步骤(可选)",
|
||||
"report-bug-field-steps-placeholder": "1. … 2. …",
|
||||
"report-bug-meta": "版本 {{version}} · {{os}} ({{arch}})",
|
||||
"report-bug-description-required": "请简要描述问题。",
|
||||
"report-bug-submit": "保存诊断并打开邮件",
|
||||
"report-bug-diagnostics-saved": "诊断已保存。请在邮件中附上 ZIP 后再发送。",
|
||||
"report-bug-mail-subject": "[Eigent] 问题反馈",
|
||||
"report-bug-mail-body-intro": "请附上您刚保存的诊断 ZIP 文件。",
|
||||
"report-bug-mail-body-path": "ZIP 保存位置:{{path}}",
|
||||
"report-bug-mail-body-meta": "应用版本:{{version}} | 系统:{{os}} ({{arch}})",
|
||||
"report-bug-mail-body-desc": "问题描述:",
|
||||
"report-bug-mail-body-steps": "复现步骤:",
|
||||
"report-bug-mail-body-truncated": "因长度限制,邮件正文已截断。完整内容见 ZIP 内的 bug_report.txt。",
|
||||
"refer-friends": "推荐朋友",
|
||||
"retry": "重试",
|
||||
"please-enter-email-address": "请输入邮箱地址",
|
||||
|
|
|
|||
17
src/types/electron.d.ts
vendored
17
src/types/electron.d.ts
vendored
|
|
@ -64,6 +64,23 @@ interface ElectronAPI {
|
|||
getShowWebview: () => Promise<any>;
|
||||
webviewDestroy: (webviewId: string) => Promise<any>;
|
||||
exportLog: () => Promise<any>;
|
||||
getDiagnosticsInfo: () => Promise<{
|
||||
version: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}>;
|
||||
exportDiagnosticsZip: (payload: {
|
||||
description: string;
|
||||
steps?: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
savedPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openMailto: (url: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
mcpInstall: (name: string, mcp: any) => Promise<any>;
|
||||
mcpRemove: (name: string) => Promise<any>;
|
||||
mcpUpdate: (name: string, mcp: any) => Promise<any>;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ export interface MockedElectronAPI {
|
|||
checkAndInstallDepsOnUpdate: ReturnType<typeof vi.fn>;
|
||||
getInstallationStatus: ReturnType<typeof vi.fn>;
|
||||
exportLog: ReturnType<typeof vi.fn>;
|
||||
getDiagnosticsInfo: ReturnType<typeof vi.fn>;
|
||||
exportDiagnosticsZip: ReturnType<typeof vi.fn>;
|
||||
openMailto: ReturnType<typeof vi.fn>;
|
||||
onInstallDependenciesStart: ReturnType<typeof vi.fn>;
|
||||
onInstallDependenciesLog: ReturnType<typeof vi.fn>;
|
||||
onInstallDependenciesComplete: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -199,6 +202,25 @@ export function createElectronAPIMock(): MockedElectronAPI {
|
|||
};
|
||||
}),
|
||||
|
||||
getDiagnosticsInfo: vi.fn().mockImplementation(async () => {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
};
|
||||
}),
|
||||
|
||||
exportDiagnosticsZip: vi.fn().mockImplementation(async () => {
|
||||
return {
|
||||
success: true,
|
||||
savedPath: '/mock/diagnostics.zip',
|
||||
};
|
||||
}),
|
||||
|
||||
openMailto: vi.fn().mockImplementation(async () => {
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Event listeners
|
||||
onInstallDependenciesStart: vi
|
||||
.fn()
|
||||
|
|
@ -410,6 +432,9 @@ export function createElectronAPIMock(): MockedElectronAPI {
|
|||
electronAPI.checkAndInstallDepsOnUpdate.mockClear();
|
||||
electronAPI.getInstallationStatus.mockClear();
|
||||
electronAPI.exportLog.mockClear();
|
||||
electronAPI.getDiagnosticsInfo.mockClear();
|
||||
electronAPI.exportDiagnosticsZip.mockClear();
|
||||
electronAPI.openMailto.mockClear();
|
||||
electronAPI.onInstallDependenciesStart.mockClear();
|
||||
electronAPI.onInstallDependenciesLog.mockClear();
|
||||
electronAPI.onInstallDependenciesComplete.mockClear();
|
||||
|
|
|
|||
82
test/unit/components/Dialog/ReportBugDialog.test.tsx
Normal file
82
test/unit/components/Dialog/ReportBugDialog.test.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
|
||||
import { useHost } from '@/host';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
vi.mock('@/host', () => ({
|
||||
useHost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ReportBugDialog', () => {
|
||||
const mockUseHost = vi.mocked(useHost);
|
||||
const mockToast = vi.mocked(toast);
|
||||
|
||||
const mockElectronAPI = {
|
||||
exportLog: vi.fn().mockResolvedValue({ success: true }),
|
||||
getDiagnosticsInfo: vi.fn().mockResolvedValue({
|
||||
version: '1.0.0',
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
}),
|
||||
exportDiagnosticsZip: vi.fn(),
|
||||
openMailto: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseHost.mockReturnValue({ electronAPI: mockElectronAPI } as any);
|
||||
});
|
||||
|
||||
it('silently stops when diagnostics save is canceled', async () => {
|
||||
mockElectronAPI.exportDiagnosticsZip.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
render(<ReportBugDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
await userEvent.type(
|
||||
screen.getByLabelText('layout.report-bug-field-description'),
|
||||
'A short repro description'
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'layout.report-bug-submit' })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockElectronAPI.exportDiagnosticsZip).toHaveBeenCalledWith({
|
||||
description: 'A short repro description',
|
||||
steps: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockElectronAPI.openMailto).not.toHaveBeenCalled();
|
||||
expect(mockToast.error).not.toHaveBeenCalled();
|
||||
expect(mockToast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue