diff --git a/packages/app/e2e/sidebar/sidebar-session-organization.spec.ts b/packages/app/e2e/sidebar/sidebar-session-organization.spec.ts index dc3beb2ae..ca14154c4 100644 --- a/packages/app/e2e/sidebar/sidebar-session-organization.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-organization.spec.ts @@ -76,6 +76,31 @@ test("users can delete a session from the PawWork sidebar", async ({ page, sdk, await expect(sidebar.locator(`[data-session-id="${session.id}"]`)).toHaveCount(0) }) +test("session row menu does not expose raw export as a troubleshooting entry", async ({ page, sdk, gotoSession }) => { + const stamp = Date.now() + const session = await sdk.session.create({ title: `e2e sidebar diagnostics ${stamp}` }).then((r) => r.data) + + if (!session?.id) throw new Error("missing session id") + + try { + await gotoSession(session.id) + await openSidebar(page) + + const sidebar = page.locator(pawworkSidebarSelector).first() + const row = sidebar.locator(`[data-session-id="${session.id}"]`).first() + await expect(row).toBeVisible() + + await row.hover() + await row.locator('[data-action="session-row-menu"]').click() + + await expect(page.getByRole("menuitem", { name: /rename/i })).toBeVisible() + await expect(page.getByRole("menuitem", { name: /export session/i })).toHaveCount(0) + await expect(page.getByRole("menuitem", { name: /diagnostics package/i })).toHaveCount(0) + } finally { + await cleanupSession({ sdk, sessionID: session.id }) + } +}) + test("previous and next session follow time order across PawWork projects", async ({ page, backend, project }) => { const stamp = Date.now() const other = await createTestProject({ serverUrl: backend.url }) diff --git a/packages/app/src/desktop-api-contract.ts b/packages/app/src/desktop-api-contract.ts index ae7edc252..ca06bfeb3 100644 --- a/packages/app/src/desktop-api-contract.ts +++ b/packages/app/src/desktop-api-contract.ts @@ -44,6 +44,14 @@ export type ReportProblemResult = | { status: "ready"; fileName: string; locationHint: string } | { status: "failed" } } + | { + status: "package-only" + summaryCopied: true + feedbackOpened: false + fullReport: + | { status: "ready"; fileName: string; locationHint: string } + | { status: "failed" } + } | { status: "cancelled"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "none" } } | { status: "unavailable"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "none" } } | { status: "failed"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "failed" } } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 28fa8e0ec..ecf40d321 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -504,30 +504,32 @@ export const dict = { "error.page.known.localState.title": "Local state problem", "error.page.known.localState.description": "PawWork had trouble reading local state for this workspace. Your original project files are usually not affected.", - "error.page.report.action": "Report a problem", - "error.page.report.preparing": "Preparing report...", + "error.page.report.action": "Prepare diagnostics package", + "error.page.report.preparing": "Preparing diagnostics package...", "error.page.report.githubFallback": "You can also report this on GitHub.", "error.page.report.formFallbackAction": "Open the feedback form manually.", - "error.page.report.confirm.description": "PawWork will prepare a problem report and open the feedback form.", + "error.page.report.confirm.description": "PawWork will prepare one diagnostics package and open the feedback form.", "error.page.report.confirm.privacy": - "The report is for diagnostics. It will not automatically upload your original project files.", + "The package can include conversation content, app logs, local paths, and environment details. It will not automatically upload your original project files.", "error.page.report.confirm.details": "See what is included", "error.page.report.confirm.item.error": "Error summary and full error details", - "error.page.report.confirm.item.app": "App version and system information", + "error.page.report.confirm.item.app": "App version, system information, and environment details", "error.page.report.confirm.item.logs": "Recent diagnostic logs", - "error.page.report.confirm.item.context": "Current page, workspace, and session context", - "error.page.report.confirm.continue": "Prepare report and open form", + "error.page.report.confirm.item.context": "Current page, workspace path, and session context", + "error.page.report.confirm.continue": "Prepare package and open form", "error.page.report.success": - "The feedback form is open. A short summary was copied, and the full report was saved locally for manual upload.", + "The feedback form is open. A short summary was copied, and the diagnostics package was saved locally for manual upload.", "error.page.report.summaryOnly": "The current error summary was copied. Paste it into the feedback form.", "error.page.report.formFallback": "The feedback form did not open automatically. Open the link manually, then paste the copied summary.", + "error.page.report.packageOnly": + "The diagnostics package was saved locally. This build does not have a feedback form configured, so attach the package when reporting this.", "error.page.report.failed": - "PawWork could not prepare the report. Use the technical details below when reporting this.", + "PawWork could not prepare the diagnostics package. Use the technical details below when reporting this.", "error.page.report.copiedFallback": - "PawWork could not prepare the report. The current error details were copied instead.", + "PawWork could not prepare the diagnostics package. The current error details were copied instead.", "error.page.report.unavailable": - "Problem reporting is not available in this build. Use the GitHub link or the technical details below.", + "Diagnostics packages are not available in this build. Use the GitHub link or the technical details below.", "error.page.version": "Version: {{version}}", "error.dev.rootNotFound": @@ -744,9 +746,9 @@ export const dict = { "session.share.copy.copied": "Copied", "session.share.copy.copyLink": "Copy link", - "session.export.action.export": "Export session", - "session.export.success": "Session exported", - "session.export.error.failed": "Export failed", + "session.export.action.export": "Prepare diagnostics package", + "session.export.success": "Diagnostics package ready", + "session.export.error.failed": "Diagnostics package failed", "lsp.tooltip.none": "No LSP servers", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c18a0b722..694aaf767 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -488,24 +488,25 @@ export const dict = { "error.page.known.localState.title": "本地状态问题", "error.page.known.localState.description": "爪印在读取这个工作区的本地状态时遇到了问题。这通常不影响你的原始项目文件。", - "error.page.report.action": "报告问题", - "error.page.report.preparing": "正在准备报告...", + "error.page.report.action": "准备诊断包", + "error.page.report.preparing": "正在准备诊断包...", "error.page.report.githubFallback": "也可以在 GitHub 反馈。", "error.page.report.formFallbackAction": "手动打开反馈表单。", - "error.page.report.confirm.description": "爪印会准备一份问题报告并打开反馈表单。", - "error.page.report.confirm.privacy": "报告用于排查问题,默认不会自动上传你的原始项目文件。", + "error.page.report.confirm.description": "爪印会准备一份诊断包并打开反馈表单。", + "error.page.report.confirm.privacy": "诊断包可能包含会话内容、应用日志、本地路径和环境信息。不会自动上传你的原始项目文件。", "error.page.report.confirm.details": "查看会包含哪些信息", "error.page.report.confirm.item.error": "错误摘要和完整错误详情", - "error.page.report.confirm.item.app": "应用版本和系统信息", + "error.page.report.confirm.item.app": "应用版本、系统信息和环境信息", "error.page.report.confirm.item.logs": "最近的诊断日志", - "error.page.report.confirm.item.context": "当前页面、工作区和会话上下文", - "error.page.report.confirm.continue": "准备报告并打开表单", - "error.page.report.success": "反馈表单已打开。摘要已复制,完整报告已保存到本地,可手动上传。", + "error.page.report.confirm.item.context": "当前页面、工作区路径和会话上下文", + "error.page.report.confirm.continue": "准备诊断包并打开表单", + "error.page.report.success": "反馈表单已打开。摘要已复制,诊断包已保存到本地,可手动上传。", "error.page.report.summaryOnly": "已复制当前错误摘要。请在表单里粘贴这段内容。", "error.page.report.formFallback": "反馈表单没有自动打开。请手动打开链接,然后粘贴已复制的摘要。", - "error.page.report.failed": "爪印无法准备问题报告。反馈时请使用下面的技术详情。", - "error.page.report.copiedFallback": "爪印无法准备问题报告。已改为复制当前错误详情。", - "error.page.report.unavailable": "当前构建不支持问题报告。请使用 GitHub 链接,或复制下面的技术详情。", + "error.page.report.packageOnly": "诊断包已保存到本地。当前构建没有配置反馈表单,反馈时请附上这份诊断包。", + "error.page.report.failed": "爪印无法准备诊断包。反馈时请使用下面的技术详情。", + "error.page.report.copiedFallback": "爪印无法准备诊断包。已改为复制当前错误详情。", + "error.page.report.unavailable": "当前构建不支持诊断包。请使用 GitHub 链接,或复制下面的技术详情。", "error.page.version": "版本:{{version}}", "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?", "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?", @@ -676,9 +677,9 @@ export const dict = { "session.share.copy.copied": "已复制", "session.share.copy.copyLink": "复制链接", - "session.export.action.export": "导出会话", - "session.export.success": "会话已导出", - "session.export.error.failed": "导出失败", + "session.export.action.export": "准备诊断包", + "session.export.success": "诊断包已准备好", + "session.export.error.failed": "诊断包准备失败", "lsp.tooltip.none": "没有 LSP 服务器", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/pages/error-report.test.ts b/packages/app/src/pages/error-report.test.ts index f880d531f..4b1099200 100644 --- a/packages/app/src/pages/error-report.test.ts +++ b/packages/app/src/pages/error-report.test.ts @@ -15,14 +15,16 @@ const t: ErrorReportTranslator = (key, vars) => { "error.page.known.localState.description": "PawWork had trouble reading local state for this workspace. Your original project files are usually not affected.", "error.page.report.success": - "The feedback form is open. A short summary was copied, and the full report was saved locally for manual upload.", + "The feedback form is open. A short summary was copied, and the diagnostics package was saved locally for manual upload.", "error.page.report.summaryOnly": "The current error summary was copied. Paste it into the feedback form.", "error.page.report.formFallback": "The feedback form did not open automatically. Open the link manually, then paste the copied summary.", + "error.page.report.packageOnly": + "The diagnostics package was saved locally. This build does not have a feedback form configured.", "error.page.report.failed": - "PawWork could not prepare the report. Use the technical details below when reporting this.", + "PawWork could not prepare the diagnostics package. Use the technical details below when reporting this.", "error.page.report.unavailable": - "Problem reporting is not available in this build. Use the GitHub link or the technical details below.", + "Diagnostics packages are not available in this build. Use the GitHub link or the technical details below.", "error.chain.causedBy": "Caused by", "error.page.circular": "[Circular]", } @@ -141,6 +143,16 @@ describe("error page reporting helpers", () => { locationHint: "PawWork app data/.../problem-reports/pawwork-problem-report.md", }, } + const packageOnly: ReportProblemResult = { + status: "package-only", + summaryCopied: true, + feedbackOpened: false, + fullReport: { + status: "ready", + fileName: "pawwork-problem-report.md", + locationHint: "PawWork app data/.../problem-reports/pawwork-problem-report.md", + }, + } const failed: ReportProblemResult = { status: "failed", summaryCopied: false, @@ -161,7 +173,7 @@ describe("error page reporting helpers", () => { } expect(errorReportStatusMessage(ready, t)).toBe( - "The feedback form is open. A short summary was copied, and the full report was saved locally for manual upload.", + "The feedback form is open. A short summary was copied, and the diagnostics package was saved locally for manual upload.", ) expect(errorReportStatusMessage(summaryOnly, t)).toBe( "The current error summary was copied. Paste it into the feedback form.", @@ -169,12 +181,15 @@ describe("error page reporting helpers", () => { expect(errorReportStatusMessage(fallback, t)).toBe( "The feedback form did not open automatically. Open the link manually, then paste the copied summary.", ) + expect(errorReportStatusMessage(packageOnly, t)).toBe( + "The diagnostics package was saved locally. This build does not have a feedback form configured.", + ) expect(errorReportStatusMessage(failed, t)).toBe( - "PawWork could not prepare the report. Use the technical details below when reporting this.", + "PawWork could not prepare the diagnostics package. Use the technical details below when reporting this.", ) expect(errorReportStatusMessage(cancelled, t)).toBeUndefined() expect(errorReportStatusMessage(unavailable, t)).toBe( - "Problem reporting is not available in this build. Use the GitHub link or the technical details below.", + "Diagnostics packages are not available in this build. Use the GitHub link or the technical details below.", ) }) }) diff --git a/packages/app/src/pages/error-report.ts b/packages/app/src/pages/error-report.ts index 9505a1424..08d47b973 100644 --- a/packages/app/src/pages/error-report.ts +++ b/packages/app/src/pages/error-report.ts @@ -266,6 +266,7 @@ export function errorReportStatusMessage(result: ReportProblemResult, t: ErrorRe if (result.status === "ready") return t("error.page.report.success") if (result.status === "summary-only") return t("error.page.report.summaryOnly") if (result.status === "form-fallback") return t("error.page.report.formFallback", { url: result.feedbackUrl }) + if (result.status === "package-only") return t("error.page.report.packageOnly") if (result.status === "cancelled") return undefined if (result.status === "unavailable") return t("error.page.report.unavailable") return t("error.page.report.failed") diff --git a/packages/app/src/pages/layout/pawwork-session-commands.test.ts b/packages/app/src/pages/layout/pawwork-session-commands.test.ts index 27e78d246..f5e522aef 100644 --- a/packages/app/src/pages/layout/pawwork-session-commands.test.ts +++ b/packages/app/src/pages/layout/pawwork-session-commands.test.ts @@ -148,10 +148,10 @@ describe("createPawworkSessionCommands", () => { }) }) - test("exportSessionAvailable requires platform export and a sidecar server", () => { + test("exportSessionAvailable no longer exposes raw session JSON export", () => { createRoot((dispose) => { const sidecar = setup({ exportSession: (() => Promise.resolve({ ok: true, path: "" })) as never, serverCurrent: { type: "sidecar" } }) - expect(createPawworkSessionCommands(sidecar.input).exportSessionAvailable()).toBe(true) + expect(createPawworkSessionCommands(sidecar.input).exportSessionAvailable()).toBe(false) const remote = setup({ exportSession: (() => Promise.resolve({ ok: true, path: "" })) as never, serverCurrent: { type: "remote" } }) expect(createPawworkSessionCommands(remote.input).exportSessionAvailable()).toBe(false) diff --git a/packages/app/src/pages/layout/pawwork-session-commands.ts b/packages/app/src/pages/layout/pawwork-session-commands.ts index 9e4bbbebe..41da35d16 100644 --- a/packages/app/src/pages/layout/pawwork-session-commands.ts +++ b/packages/app/src/pages/layout/pawwork-session-commands.ts @@ -49,12 +49,10 @@ export function createPawworkSessionCommands(input: PawworkSessionCommandsInput) } } - // Export hits the embedded sidecar via main-process IPC. When the user has - // switched the active server to a remote target, the sidecar holds different - // data than the UI; hide the action rather than ship a misleading export. - const exportSessionAvailable = createMemo( - () => !!input.platform.exportSession && input.server.current?.type === "sidecar", - ) + // The old raw JSON session export is intentionally hidden from the sidebar. + // Troubleshooting now goes through Help -> diagnostics package so logs, + // renderer diagnostics, environment context, and session content stay together. + const exportSessionAvailable = createMemo(() => false) async function exportSession(session: Session) { if (!input.platform.exportSession) return diff --git a/packages/desktop-electron/scripts/report-problem-smoke.mjs b/packages/desktop-electron/scripts/report-problem-smoke.mjs index eb0d2894b..43314ecce 100644 --- a/packages/desktop-electron/scripts/report-problem-smoke.mjs +++ b/packages/desktop-electron/scripts/report-problem-smoke.mjs @@ -122,6 +122,8 @@ try { markdownHasRendererError: report.markdown.includes(rendererError.summary) && report.markdown.includes('\\"kind\\":\\"manual-smoke\\"'), markdownHasReportPayload: report.markdown.includes("```json"), + markdownHasMainLog: report.markdown.includes("== Main process log:"), + markdownHasBackendLog: report.markdown.includes("== Backend log:"), } console.log(JSON.stringify(summary, null, 2)) @@ -132,6 +134,8 @@ try { assert(summary.clipboardRedactedStorage, "expected clipboard summary to redact storage and key diagnostics") assert(summary.markdownHasRendererError, "expected full report to include renderer error details") assert(summary.markdownHasReportPayload, "expected full report to include the fenced JSON payload") + assert(summary.markdownHasMainLog, "expected full report to include main process log tail") + assert(summary.markdownHasBackendLog, "expected full report to include backend log tail") } finally { await closeApp(app) rmSync(homeDir, { force: true, maxRetries: 5, recursive: true, retryDelay: 100 }) diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts index af8e445d2..7a184f94f 100644 --- a/packages/desktop-electron/src/main/feedback.test.ts +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -89,27 +89,43 @@ function setup(overrides: Partial[0]> = describe("feedback handler", () => { test("has localized confirmation labels for Simplified Chinese", () => { - expect(feedbackDialogLabels("zh").title).toBe("准备问题报告?") - expect(feedbackDialogLabels("zh").confirm).toBe("复制摘要并打开表单") - expect(feedbackDialogLabels("zh").message).toContain("简短摘要") - expect(feedbackDialogLabels("zh").message).toContain("完整问题报告文件") - expect(feedbackDialogLabels("zh").message).toContain("提交后可以删除") + expect(feedbackDialogLabels("zh").title).toBe("准备诊断包?") + expect(feedbackDialogLabels("zh").confirm).toBe("准备诊断包并打开表单") + expect(feedbackDialogLabels("zh").message).toContain("会话内容") + expect(feedbackDialogLabels("zh").message).toContain("界面诊断") + expect(feedbackDialogLabels("zh").message).toContain("应用日志") + expect(feedbackDialogLabels("zh").message).toContain("环境信息") + expect(feedbackDialogLabels("zh").message).toContain("本地路径") expect(feedbackDialogLabels("zh").message).not.toContain("PawWork") expect(feedbackDialogLabels("zh").formOpenFailedMessage).not.toContain("PawWork") }) test("has English confirmation labels", () => { - expect(feedbackDialogLabels("en").title).toBe("Prepare problem report?") - expect(feedbackDialogLabels("en").confirm).toBe("Copy summary and open form") - expect(feedbackDialogLabels("en").message).toContain("short summary") - expect(feedbackDialogLabels("en").message).toContain("full problem report file") - expect(feedbackDialogLabels("en").message).toContain("delete the local full report file after submission") - expect(feedbackDialogLabels("en").failedTitle).toBe("Problem Report Failed") + expect(feedbackDialogLabels("en").title).toBe("Prepare diagnostics package?") + expect(feedbackDialogLabels("en").confirm).toBe("Prepare package and open form") + expect(feedbackDialogLabels("en").message).toContain("session content") + expect(feedbackDialogLabels("en").message).toContain("renderer diagnostics") + expect(feedbackDialogLabels("en").message).toContain("app logs") + expect(feedbackDialogLabels("en").message).toContain("environment information") + expect(feedbackDialogLabels("en").message).toContain("local paths") + expect(feedbackDialogLabels("en").failedTitle).toBe("Diagnostics Package Failed") + }) + + test("has package-only confirmation labels when no feedback form is configured", () => { + const english = feedbackDialogLabels("en", { withForm: false }) + expect(english.confirm).toBe("Prepare package") + expect(english.message).toContain("This build does not have a feedback form configured") + expect(english.message).not.toContain("open the feedback form") + + const chinese = feedbackDialogLabels("zh", { withForm: false }) + expect(chinese.confirm).toBe("准备诊断包") + expect(chinese.message).toContain("当前构建没有配置反馈表单") + expect(chinese.message).not.toContain("打开反馈表单") }) test("falls back to English confirmation labels", () => { - expect(feedbackDialogLabels("fr" as never).title).toBe("Prepare problem report?") - expect(feedbackDialogLabels("fr" as never).confirm).toBe("Copy summary and open form") + expect(feedbackDialogLabels("fr" as never).title).toBe("Prepare diagnostics package?") + expect(feedbackDialogLabels("fr" as never).confirm).toBe("Prepare package and open form") }) test("cancel does not copy or open", async () => { @@ -387,11 +403,23 @@ describe("feedback handler", () => { expect(subject.calls.handledErrors).toContain("problem report cleanup failed") }) - test("missing feedback URL does not copy or open", async () => { + test("missing feedback URL still prepares a local diagnostics package", async () => { const subject = setup({ feedbackUrl: "" }) - await subject.handler() - expect(subject.calls.copied).toBe("") + const result = await subject.handler() + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.savedMarkdown).toContain("# PawWork Problem Report") + expect(subject.calls.shown).toContain("/tmp/pawwork/problem-reports/") expect(subject.calls.opened).toBe("") + expect(result).toEqual({ + status: "package-only", + summaryCopied: true, + feedbackOpened: false, + fullReport: { + status: "ready", + fileName: expect.stringContaining("pawwork-problem-report-"), + locationHint: expect.stringContaining("problem-reports"), + }, + }) }) test("non-session failures are reported without rejecting", async () => { diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts index d5cec069c..2841e029f 100644 --- a/packages/desktop-electron/src/main/feedback.ts +++ b/packages/desktop-electron/src/main/feedback.ts @@ -52,30 +52,33 @@ type FeedbackDeps = { type FeedbackInput = ReportProblemInput export type FeedbackResult = ReportProblemResult -export function feedbackDialogLabels(locale: MenuLocale) { +export function feedbackDialogLabels(locale: MenuLocale, options: { withForm?: boolean } = {}) { + const withForm = options.withForm ?? true const labels = { en: { - title: "Prepare problem report?", - message: - "PawWork will copy a short summary to your clipboard, save a full problem report file locally, and open the feedback form.\n\nUpload the full problem report file if the form asks for details. You can delete the local full report file after submission.", - confirm: "Copy summary and open form", + title: "Prepare diagnostics package?", + message: withForm + ? "PawWork will save one diagnostics package locally, copy a short summary, and open the feedback form.\n\nThe package can include session content, renderer diagnostics, app logs, local paths, and environment information. Review it before uploading. You can delete the local package after submission." + : "PawWork will save one diagnostics package locally and copy a short summary.\n\nThe package can include session content, renderer diagnostics, app logs, local paths, and environment information. Review it before sharing. This build does not have a feedback form configured.", + confirm: withForm ? "Prepare package and open form" : "Prepare package", cancel: "Cancel", - failedTitle: "Problem Report Failed", - failedMessage: "Could not prepare the problem report. You can try Report a Problem again.", + failedTitle: "Diagnostics Package Failed", + failedMessage: "Could not prepare the diagnostics package. You can try preparing it again.", formOpenFailedTitle: "Feedback Form Did Not Open", formOpenFailedMessage: - "PawWork prepared the problem report, but could not open the feedback form. Open this URL manually to finish submitting feedback.", + "PawWork prepared the diagnostics package, but could not open the feedback form. Open this URL manually to finish submitting feedback.", }, zh: { - title: "准备问题报告?", - message: - "应用会复制一份简短摘要到剪贴板,保存完整问题报告文件到本地,并打开反馈表单。\n\n如果表单需要更多细节,可以上传完整问题报告文件。提交后可以删除本地完整报告文件。", - confirm: "复制摘要并打开表单", + title: "准备诊断包?", + message: withForm + ? "应用会在本地保存一份诊断包,复制简短摘要,并打开反馈表单。\n\n诊断包可能包含会话内容、界面诊断、应用日志、本地路径和环境信息。上传前可以先检查,提交后也可以删除本地诊断包。" + : "应用会在本地保存一份诊断包,并复制简短摘要。\n\n诊断包可能包含会话内容、界面诊断、应用日志、本地路径和环境信息。分享前可以先检查。当前构建没有配置反馈表单。", + confirm: withForm ? "准备诊断包并打开表单" : "准备诊断包", cancel: "取消", - failedTitle: "问题报告失败", - failedMessage: "无法准备问题报告。你可以重新点击“报告问题”再试一次。", + failedTitle: "诊断包准备失败", + failedMessage: "无法准备诊断包。你可以重新准备一次。", formOpenFailedTitle: "反馈表单未打开", - formOpenFailedMessage: "问题报告已准备好,但无法打开反馈表单。请手动打开这个链接继续提交反馈。", + formOpenFailedMessage: "诊断包已准备好,但无法打开反馈表单。请手动打开这个链接继续提交反馈。", }, } satisfies Record< MenuLocale, @@ -173,9 +176,6 @@ export function createFeedbackHandler(deps: FeedbackDeps) { input: FeedbackInput = {}, contextOverride?: FeedbackContextOverride, ): Promise { - if (!deps.feedbackUrl) { - return { status: "unavailable", summaryCopied: false, feedbackOpened: false, fullReport: { status: "none" } } - } const context = deps.context?.(contextOverride) const needsConfirm = input.confirm ?? true if (needsConfirm) { @@ -266,6 +266,17 @@ export function createFeedbackHandler(deps: FeedbackDeps) { } } + if (!deps.feedbackUrl) { + return { + status: "package-only", + summaryCopied: true, + feedbackOpened: false, + fullReport: savedReport + ? { status: "ready", fileName: savedReport.fileName, locationHint: savedReport.locationHint } + : { status: "failed" }, + } + } + try { await deps.openExternal(deps.feedbackUrl) } catch (error) { diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 21a6813f8..bbb339a6a 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -77,7 +77,7 @@ import { createFeedbackHandler, feedbackDialogLabels } from "./feedback" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { registerAboutIpc, triggerAbout } from "./ipc/about" import { registerBrowserIpc } from "./ipc/browser" -import { filePath, initLogging, tail } from "./logging" +import { diagnosticsLogTail, filePath, initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { menuLabel, type MenuLocale } from "./menu-labels" @@ -89,7 +89,7 @@ import { rendererDiagnosticsRoot, SESSION_EXPORT_RENDERER_DIAGNOSTICS_MAX_BYTES, } from "./renderer-diagnostics" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { backendLogFilePath, getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" import { createUpdaterController, createUpdateFeed, githubFeed, r2Feed, type FeedTarget } from "./updater" import { pendingUpdateCacheDir } from "./updater-cache" @@ -303,7 +303,9 @@ const reportProblem = createFeedbackHandler({ reportRoot: problemReportRoot, context: currentFeedbackRuntimeContext, confirm: async (context) => { - const labels = feedbackDialogLabels(context === undefined ? menuLocale : feedbackContext(context).locale) + const labels = feedbackDialogLabels(context === undefined ? menuLocale : feedbackContext(context).locale, { + withForm: Boolean(FEEDBACK_FORM_URL), + }) const response = await dialog.showMessageBox({ type: "warning", title: labels.title, @@ -333,7 +335,7 @@ const reportProblem = createFeedbackHandler({ cleanupReports: (currentPath) => cleanupProblemReports({ root: problemReportRoot, keep: 10, currentPath }), sessionExportTimeoutMs: FEEDBACK_SESSION_EXPORT_TIMEOUT_MS, diagnostics: (context) => diagnostics(feedbackContext(context)), - logTail: tail, + logTail: () => diagnosticsLogTail({ backendLogPath: backendLogFilePath() }), sessionExport: (context, signal) => sessionExport(feedbackContext(context), signal), rendererDiagnostics: (context) => { const runtimeContext = feedbackRuntimeContext(context) diff --git a/packages/desktop-electron/src/main/logging.test.ts b/packages/desktop-electron/src/main/logging.test.ts index da73950b7..68fe87409 100644 --- a/packages/desktop-electron/src/main/logging.test.ts +++ b/packages/desktop-electron/src/main/logging.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test" -import { mkdtempSync, rmSync } from "node:fs" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" @@ -121,4 +121,24 @@ describe("desktop logging", () => { expect(consoleTransport.wrapCount).toBe(1) }) + + test("builds a diagnostics log tail from main and backend logs", async () => { + logDir = mkdtempSync(join(tmpdir(), "pawwork-logging-test-")) + const mainPath = join(logDir, "main.log") + const backendPath = join(logDir, "backend.log") + writeFileSync(mainPath, "main ok\nmain failed\n") + writeFileSync(backendPath, "backend ok\nbackend failed\n") + fakeLog.transports.file = { + maxSize: 0, + getFile: () => ({ path: mainPath }), + } + + const { diagnosticsLogTail } = await import(`./logging?logging-test=${crypto.randomUUID()}`) + const tail = diagnosticsLogTail({ backendLogPath: backendPath }) + + expect(tail).toContain("Main process log") + expect(tail).toContain("main failed") + expect(tail).toContain("Backend log") + expect(tail).toContain("backend failed") + }) }) diff --git a/packages/desktop-electron/src/main/logging.ts b/packages/desktop-electron/src/main/logging.ts index 11beea5ab..9768934a9 100644 --- a/packages/desktop-electron/src/main/logging.ts +++ b/packages/desktop-electron/src/main/logging.ts @@ -14,8 +14,24 @@ export function initLogging() { } export function tail(): string { + return tailFile(filePath()) +} + +export function diagnosticsLogTail(input: { backendLogPath?: string | null } = {}): string { + const mainPath = filePath() + const sections = [`== Main process log: ${mainPath} ==\n${tailFile(mainPath) || "(empty)"}`] + const backendLogPath = input.backendLogPath + sections.push( + backendLogPath + ? `== Backend log: ${backendLogPath} ==\n${tailFile(backendLogPath) || "(empty)"}` + : "== Backend log: unavailable ==", + ) + return sections.join("\n\n") +} + +function tailFile(path: string): string { try { - const contents = readFileSync(filePath(), "utf8") + const contents = readFileSync(path, "utf8") const lines = contents.split("\n") return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n") } catch { diff --git a/packages/desktop-electron/src/main/menu-labels.test.ts b/packages/desktop-electron/src/main/menu-labels.test.ts index ab2b9854d..ceabfaddf 100644 --- a/packages/desktop-electron/src/main/menu-labels.test.ts +++ b/packages/desktop-electron/src/main/menu-labels.test.ts @@ -41,7 +41,7 @@ describe("menu labels", () => { test("returns custom labels for simplified Chinese", () => { expect(menuLabel("zh", "file")).toBe("文件") expect(menuLabel("zh", "reloadWindow")).toBe("重新加载窗口") - expect(menuLabel("zh", "reportProblem")).toBe("报告问题") + expect(menuLabel("zh", "reportProblem")).toBe("准备诊断包...") expect(menuLabel("zh", "exportDiagnosticsLog")).toBe("导出诊断日志...") expect(menuLabel("zh", "exportDiagnosticsLogTitle")).toBe("导出诊断日志") expect(menuLabel("zh", "pawworkOnGithub")).toBe("在 GitHub 上查看爪印") diff --git a/packages/desktop-electron/src/main/menu-labels.ts b/packages/desktop-electron/src/main/menu-labels.ts index 803d83d5e..2fbd7c0af 100644 --- a/packages/desktop-electron/src/main/menu-labels.ts +++ b/packages/desktop-electron/src/main/menu-labels.ts @@ -75,7 +75,7 @@ const labels: Record> = { previousProject: "Previous Project", nextProject: "Next Project", pawworkOnGithub: "PawWork on GitHub", - reportProblem: "Report a Problem", + reportProblem: "Prepare Diagnostics Package...", exportDiagnosticsLog: "Export Diagnostics Log...", exportDiagnosticsLogTitle: "Export Diagnostics Log", openGithubIssue: "Open GitHub Issue", @@ -103,7 +103,7 @@ const labels: Record> = { previousProject: "上一个项目", nextProject: "下一个项目", pawworkOnGithub: "在 GitHub 上查看爪印", - reportProblem: "报告问题", + reportProblem: "准备诊断包...", exportDiagnosticsLog: "导出诊断日志...", exportDiagnosticsLogTitle: "导出诊断日志", openGithubIssue: "打开 GitHub Issue", diff --git a/packages/desktop-electron/src/main/menu-template.test.ts b/packages/desktop-electron/src/main/menu-template.test.ts index 73d8ca3af..d04052a5f 100644 --- a/packages/desktop-electron/src/main/menu-template.test.ts +++ b/packages/desktop-electron/src/main/menu-template.test.ts @@ -36,13 +36,19 @@ test("Windows Help submenu contains 'Check for Updates' and 'About PawWork'", () expect(labels).toContain("About PawWork") }) -test("Help submenu exposes diagnostics export", () => { +test("Help submenu exposes only the complete diagnostics package entry", () => { const windows = buildWindowsMenuTemplate(baseOptions) const macos = buildMacosMenuTemplate(baseOptions) expect((windows.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).toContain( - "Export Diagnostics Log...", + "Prepare Diagnostics Package...", ) expect((macos.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).toContain( + "Prepare Diagnostics Package...", + ) + expect((windows.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).not.toContain( + "Export Diagnostics Log...", + ) + expect((macos.find((m) => m.label === "Help")?.submenu ?? []).map((s) => s.label)).not.toContain( "Export Diagnostics Log...", ) }) diff --git a/packages/desktop-electron/src/main/menu-template.ts b/packages/desktop-electron/src/main/menu-template.ts index 842cf147d..f314a09cc 100644 --- a/packages/desktop-electron/src/main/menu-template.ts +++ b/packages/desktop-electron/src/main/menu-template.ts @@ -33,7 +33,7 @@ type BuildMenuOptions = { } export function buildMacosMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { - const { deps, appName, locale, feedbackEnabled } = options + const { deps, appName, locale } = options const t = (key: Parameters[1]) => menuLabel(locale, key) const roleLabel = (key: MenuRoleLabelKey) => menuRoleLabel(locale, key, appName) @@ -42,11 +42,8 @@ export function buildMacosMenuTemplate(options: BuildMenuOptions): MenuItemTempl { type: "separator" }, ] - if (feedbackEnabled) { - helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) - } + helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) - helpSubmenu.push({ label: t("exportDiagnosticsLog"), click: () => deps.exportDiagnosticsLog() }) helpSubmenu.push({ label: t("openGithubIssue"), click: () => deps.openExternal(PAWWORK_GITHUB_ISSUE_URL) }) return [ @@ -148,7 +145,7 @@ export function buildMacosMenuTemplate(options: BuildMenuOptions): MenuItemTempl } export function buildWindowsMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { - const { deps, locale, feedbackEnabled, appName } = options + const { deps, locale, appName } = options const t = (key: Parameters[1]) => menuLabel(locale, key) const roleLabel = (key: MenuRoleLabelKey) => menuRoleLabel(locale, key, appName) @@ -156,10 +153,7 @@ export function buildWindowsMenuTemplate(options: BuildMenuOptions): MenuItemTem { label: t("pawworkOnGithub"), click: () => deps.openExternal(PAWWORK_GITHUB_URL) }, { type: "separator" }, ] - if (feedbackEnabled) { - helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) - } - helpSubmenu.push({ label: t("exportDiagnosticsLog"), click: () => deps.exportDiagnosticsLog() }) + helpSubmenu.push({ label: t("reportProblem"), click: () => deps.reportProblem() }) helpSubmenu.push({ label: t("openGithubIssue"), click: () => deps.openExternal(PAWWORK_GITHUB_ISSUE_URL) }) helpSubmenu.push({ type: "separator" }) helpSubmenu.push({ label: t("checkForUpdates"), click: () => deps.checkForUpdates() }) diff --git a/packages/desktop-electron/src/main/menu.test.ts b/packages/desktop-electron/src/main/menu.test.ts index 0a8a2877e..b86e5c228 100644 --- a/packages/desktop-electron/src/main/menu.test.ts +++ b/packages/desktop-electron/src/main/menu.test.ts @@ -192,7 +192,7 @@ describe("desktop menu template", () => { expect(menuDeps.checkForUpdates).toHaveBeenCalled() }) - test("shows report problem only when configured and always keeps github issue", () => { + test("shows diagnostics package even without a feedback form URL and always keeps github issue", () => { const menuDeps = deps() const template = buildMacosMenuTemplate({ deps: menuDeps, @@ -203,12 +203,16 @@ describe("desktop menu template", () => { const help = submenu(template, "Help") expect(help.some((item) => item.label === "Report a Problem")).toBe(false) + expect(help.some((item) => item.label === "Prepare Diagnostics Package...")).toBe(true) + expect(help.some((item) => item.label === "Export Diagnostics Log...")).toBe(false) expect(help.some((item) => item.label === "Open GitHub Issue")).toBe(true) + help.find((item) => item.label === "Prepare Diagnostics Package...")?.click?.() help.find((item) => item.label === "Open GitHub Issue")?.click?.() + expect(menuDeps.reportProblem).toHaveBeenCalled() expect(menuDeps.openExternal).toHaveBeenCalled() }) - test("shows report problem when feedback is configured", () => { + test("shows diagnostics package when feedback is configured", () => { const menuDeps = deps() const template = buildMacosMenuTemplate({ deps: menuDeps, @@ -218,9 +222,11 @@ describe("desktop menu template", () => { }) const help = submenu(template, "Help") - expect(help.some((item) => item.label === "Report a Problem")).toBe(true) + expect(help.some((item) => item.label === "Report a Problem")).toBe(false) + expect(help.some((item) => item.label === "Prepare Diagnostics Package...")).toBe(true) + expect(help.some((item) => item.label === "Export Diagnostics Log...")).toBe(false) expect(help.some((item) => item.label === "Open GitHub Issue")).toBe(true) - help.find((item) => item.label === "Report a Problem")?.click?.() + help.find((item) => item.label === "Prepare Diagnostics Package...")?.click?.() help.find((item) => item.label === "Open GitHub Issue")?.click?.() expect(menuDeps.reportProblem).toHaveBeenCalled() expect(menuDeps.openExternal).toHaveBeenCalled() diff --git a/packages/desktop-electron/src/main/server.test.ts b/packages/desktop-electron/src/main/server.test.ts index 0e1ffac94..5ea5f0b5e 100644 --- a/packages/desktop-electron/src/main/server.test.ts +++ b/packages/desktop-electron/src/main/server.test.ts @@ -83,6 +83,14 @@ describe("desktop server runtime namespace", () => { expect(env.XDG_STATE_HOME).toBe(serverRoots.state) }) + test("tracks the backend log file after server log init", async () => { + const { backendLogFilePathForTest, setBackendLogFilePathForTest } = await import("./server") + + setBackendLogFilePathForTest("/tmp/pawwork-backend.log") + + expect(backendLogFilePathForTest()).toBe("/tmp/pawwork-backend.log") + }) + test("uses process GitHub CLI config directory before shell config directory", async () => { process.env.GH_CONFIG_DIR = "/process/gh" mockShellEnv = { GH_CONFIG_DIR: "/shell/gh", XDG_CONFIG_HOME: "/shell/config" } @@ -321,7 +329,8 @@ describe("desktop server runtime namespace", () => { globalThis.fetch = (async () => new Response(null, { status: 200 })) as typeof fetch try { - const { spawnLocalServer } = await import("./server") + const { backendLogFilePathForTest, setBackendLogFilePathForTest, spawnLocalServer } = await import("./server") + setBackendLogFilePathForTest("/tmp/stale-opencode.log") await spawnLocalServer("127.0.0.1", 4096, "secret") expect(captured).toEqual({ XDG_DATA_HOME: serverRoots.data, @@ -335,6 +344,7 @@ describe("desktop server runtime namespace", () => { expect(listenOptions).toMatchObject({ cors: [rendererOrigin], }) + expect(backendLogFilePathForTest()).toBe("") } finally { globalThis.fetch = previousFetch } diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index eb7c857ac..ee15f7f1a 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -32,6 +32,12 @@ type ProxyConfig = { noProxy?: string } +let backendLogPath = "" + +export function backendLogFilePath() { + return backendLogPath +} + export function getDefaultServerUrl(): string | null { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null @@ -60,6 +66,11 @@ export async function spawnLocalServer(hostname: string, port: number, password: await configureProxyDispatcher(process.env) const { Log, Server } = await import("virtual:opencode-server") await Log.init({ print: false, level: "WARN" }) + backendLogPath = "" + const logFile = (Log as typeof Log & { file?: () => string }).file + if (typeof logFile === "function") { + backendLogPath = logFile() + } const listener = await Server.listen({ port, hostname, @@ -212,6 +223,10 @@ export const buildServerEnvForTest = buildServerEnv export const githubConfigDirForTest = githubConfigDir export const proxyConfigFromEnvForTest = proxyConfigFromEnv export const configureProxyDispatcherForTest = configureProxyDispatcher +export const backendLogFilePathForTest = backendLogFilePath +export function setBackendLogFilePathForTest(path: string) { + backendLogPath = path +} export async function checkHealth(url: string, password?: string | null): Promise { let healthUrl: URL