mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
fix(desktop): contain renderer crashes (#3643)
* fix(desktop): contain renderer crashes Co-authored-by: multica-agent <github@multica.ai> * fix(desktop): filter renderer exit prompts Co-authored-by: multica-agent <github@multica.ai> * refactor(desktop): drop redundant page-level ErrorBoundary on issue detail The whole-page <ErrorBoundary> wrapper duplicated the new route-level errorElement (DesktopRouteErrorPage). Let render errors bubble to the root route boundary so all detail routes are contained the same way. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(desktop): add Close tab escape to route error page Reload tab recreates the same crashing path and Go to issues is a dead end when the issues route itself crashed. Add a Close tab action that destroys the crashing router entirely and falls back to a sibling tab (or a reseeded default), the only always-safe escape regardless of which route crashed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
@@ -13,6 +13,11 @@ import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
createElectronReloadPrompt,
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -224,13 +229,6 @@ function createWindow(): void {
|
||||
log(level, `${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
// Fires when the renderer process dies for any reason (OOM, crash,
|
||||
// killed). `details.reason` is the discriminator: "crashed", "oom",
|
||||
// "killed", "abnormal-exit", "launch-failed", etc.
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
log("process-gone", JSON.stringify(details));
|
||||
});
|
||||
|
||||
// Fires when loadURL / loadFile can't reach its target (dev server
|
||||
// not up yet, network blip, file missing). errorCode is a Chromium
|
||||
// net error number; -3 = ABORTED is normal during HMR and skipped.
|
||||
@@ -245,14 +243,15 @@ function createWindow(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// Fires when the preload script throws before the renderer can boot.
|
||||
// This is the one error class that NEVER reaches DevTools (preload
|
||||
// runs before any window) — without this listener it's invisible.
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
|
||||
});
|
||||
}
|
||||
|
||||
installRendererRecoveryHandlers(mainWindow as unknown as RendererRecoveryWindow, {
|
||||
isDev: is.dev,
|
||||
showReloadPrompt: createElectronReloadPrompt((options) =>
|
||||
dialog.showMessageBox(mainWindow!, options),
|
||||
),
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
installNavigationGestures(mainWindow);
|
||||
|
||||
|
||||
112
apps/desktop/src/main/renderer-recovery.test.ts
Normal file
112
apps/desktop/src/main/renderer-recovery.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
function makeWindow() {
|
||||
const windowHandlers = new Map<string, Handler>();
|
||||
const webContentsHandlers = new Map<string, Handler>();
|
||||
const reload = vi.fn();
|
||||
return {
|
||||
window: {
|
||||
on: vi.fn((event: string, handler: Handler) => windowHandlers.set(event, handler)),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: {
|
||||
on: vi.fn((event: string, handler: Handler) => webContentsHandlers.set(event, handler)),
|
||||
reload,
|
||||
},
|
||||
},
|
||||
windowHandlers,
|
||||
webContentsHandlers,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("installRendererRecoveryHandlers", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("registers production reload prompts for renderer death and preload failure without auto reloading", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
|
||||
|
||||
expect(fixture.webContentsHandlers.has("render-process-gone")).toBe(true);
|
||||
expect(fixture.webContentsHandlers.has("preload-error")).toBe(true);
|
||||
expect(fixture.windowHandlers.has("unresponsive")).toBe(true);
|
||||
expect(fixture.windowHandlers.has("responsive")).toBe(true);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
fixture.webContentsHandlers.get("preload-error")?.({}, "/preload.js", new Error("boom"));
|
||||
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not prompt when the renderer exits cleanly", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels an unresponsive prompt when the window becomes responsive again", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for sustained unresponsive windows and only reloads after user confirmation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps dev diagnostics non-prompting", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: true, showReloadPrompt, log: vi.fn() });
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
135
apps/desktop/src/main/renderer-recovery.ts
Normal file
135
apps/desktop/src/main/renderer-recovery.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export type RendererRecoveryWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
on: (event: "unresponsive" | "responsive", handler: () => void) => unknown;
|
||||
webContents: {
|
||||
on: (event: string, handler: (...args: any[]) => void) => unknown;
|
||||
reload: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ReloadPromptPayload = {
|
||||
kind: "render-process-gone" | "preload-error" | "unresponsive";
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ReloadPromptResult = "reload" | "dismiss";
|
||||
|
||||
type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
|
||||
export function installRendererRecoveryHandlers(
|
||||
window: RendererRecoveryWindow,
|
||||
{
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const maybePromptReload = (payload: ReloadPromptPayload) => {
|
||||
if (isDev) return;
|
||||
void showReloadPrompt(payload).then((result) => {
|
||||
if (result === "reload" && !window.isDestroyed()) {
|
||||
window.webContents.reload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
maybePromptReload({ kind: "render-process-gone", context: { details } });
|
||||
});
|
||||
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
kind: "preload-error",
|
||||
context: { preloadPath, error: formatError(error) },
|
||||
});
|
||||
});
|
||||
|
||||
window.on("unresponsive", () => {
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
maybePromptReload({ kind: "unresponsive", context: {} });
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (!unresponsivePromptTimer) return;
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function createElectronReloadPrompt(
|
||||
showMessageBox: (options: {
|
||||
type: "warning";
|
||||
buttons: string[];
|
||||
defaultId: number;
|
||||
cancelId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
}) => Promise<{ response: number }>,
|
||||
) {
|
||||
return async (payload: ReloadPromptPayload): Promise<ReloadPromptResult> => {
|
||||
const result = await showMessageBox({
|
||||
type: "warning",
|
||||
buttons: ["Reload", "Dismiss"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: "Multica needs to reload",
|
||||
message: rendererRecoveryMessage(payload.kind),
|
||||
detail: rendererRecoveryDetail(payload),
|
||||
});
|
||||
return result.response === 0 ? "reload" : "dismiss";
|
||||
};
|
||||
}
|
||||
|
||||
function isRecoverableRendererExit(details: unknown) {
|
||||
if (!details || typeof details !== "object") return false;
|
||||
const reason = (details as { reason?: unknown }).reason;
|
||||
return (
|
||||
reason === "crashed" ||
|
||||
reason === "oom" ||
|
||||
reason === "abnormal-exit" ||
|
||||
reason === "launch-failed" ||
|
||||
reason === "integrity-failure"
|
||||
);
|
||||
}
|
||||
|
||||
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
|
||||
switch (kind) {
|
||||
case "render-process-gone":
|
||||
return "The desktop renderer process stopped responding or crashed.";
|
||||
case "preload-error":
|
||||
return "The desktop preload script failed before the app could start.";
|
||||
case "unresponsive":
|
||||
return "The desktop window is not responding.";
|
||||
}
|
||||
}
|
||||
|
||||
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
|
||||
return [
|
||||
"Reloading is the safest recovery path for this window.",
|
||||
"",
|
||||
`kind: ${payload.kind}`,
|
||||
`context: ${JSON.stringify(payload.context)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function defaultDevLog(tag: string, ...args: unknown[]) {
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
const openModal = vi.fn();
|
||||
const reloadActiveTab = vi.fn();
|
||||
const closeActiveTab = vi.fn();
|
||||
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: {
|
||||
getState: () => ({ open: openModal }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => ({
|
||||
useTabStore: {
|
||||
getState: () => ({ reloadActiveTab, closeActiveTab }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { DesktopRouteErrorPage, formatRouteErrorReport } from "./route-error-page";
|
||||
|
||||
function Boom(): null {
|
||||
throw new Error("route render exploded");
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("DesktopRouteErrorPage", () => {
|
||||
beforeEach(() => {
|
||||
openModal.mockReset();
|
||||
reloadActiveTab.mockReset();
|
||||
closeActiveTab.mockReset();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("brands React Router route errors and offers tab reload", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Something went wrong in this tab",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /reload tab/i }));
|
||||
expect(reloadActiveTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers Close tab as the always-safe escape from a crashing route", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/acme/issues/1", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/acme/issues/1"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /close tab/i }));
|
||||
expect(closeActiveTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the existing feedback modal with a structured markdown report only after click", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/acme/issues", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/acme/issues"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(openModal).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /report error/i }));
|
||||
|
||||
expect(openModal).toHaveBeenCalledWith(
|
||||
"feedback",
|
||||
expect.objectContaining({
|
||||
initialMessage: expect.stringContaining("kind: desktop_route_error"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("documents the structured kind/context follow-up debt in the report template", () => {
|
||||
const report = formatRouteErrorReport({
|
||||
error: new Error("bad route"),
|
||||
url: "app://desktop/acme/issues",
|
||||
appInfo: { version: "1.2.3", os: "macos" },
|
||||
trigger: "route-errorElement",
|
||||
});
|
||||
|
||||
expect(report).toContain("kind: desktop_route_error");
|
||||
expect(report).toContain("trigger: route-errorElement");
|
||||
expect(report).toContain("TODO: promote kind/context to structured feedback fields");
|
||||
});
|
||||
});
|
||||
140
apps/desktop/src/renderer/src/components/route-error-page.tsx
Normal file
140
apps/desktop/src/renderer/src/components/route-error-page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocation, useNavigate, useRouteError } from "react-router-dom";
|
||||
import { AlertTriangle, RotateCw, Send, X } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
type DesktopAppInfo = {
|
||||
version?: string;
|
||||
os?: string;
|
||||
};
|
||||
|
||||
export function formatRouteErrorReport({
|
||||
error,
|
||||
url,
|
||||
appInfo,
|
||||
trigger,
|
||||
}: {
|
||||
error: unknown;
|
||||
url: string;
|
||||
appInfo?: DesktopAppInfo;
|
||||
trigger: string;
|
||||
}) {
|
||||
const normalized = normalizeError(error);
|
||||
return [
|
||||
"kind: desktop_route_error",
|
||||
`trigger: ${trigger}`,
|
||||
`url: ${url}`,
|
||||
`app_version: ${appInfo?.version ?? "unknown"}`,
|
||||
`runtime_os: ${appInfo?.os ?? "unknown"}`,
|
||||
"",
|
||||
"context:",
|
||||
`- name: ${normalized.name}`,
|
||||
`- message: ${normalized.message}`,
|
||||
"",
|
||||
"stack:",
|
||||
"```",
|
||||
normalized.stack ?? "<no stack>",
|
||||
"```",
|
||||
"",
|
||||
"TODO: promote kind/context to structured feedback fields once the feedback API supports them.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function DesktopRouteErrorPage() {
|
||||
const error = useRouteError();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const workspaceSlug = location.pathname.split("/").filter(Boolean)[0];
|
||||
const safeRoute = workspaceSlug ? `/${workspaceSlug}/issues` : null;
|
||||
const report = useMemo(
|
||||
() =>
|
||||
formatRouteErrorReport({
|
||||
error,
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}${location.pathname}${location.search}${location.hash}`
|
||||
: location.pathname,
|
||||
appInfo: typeof window !== "undefined" ? window.desktopAPI?.appInfo : undefined,
|
||||
trigger: "route-errorElement",
|
||||
}),
|
||||
[error, location.hash, location.pathname, location.search],
|
||||
);
|
||||
const message = normalizeError(error).message;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-full min-h-[20rem] flex-col items-center justify-center gap-4 p-8 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Something went wrong in this tab</h2>
|
||||
<p className="max-w-lg text-sm text-muted-foreground">
|
||||
A route-level renderer error was contained before it could take down the
|
||||
desktop shell. Reload this tab, or send the report if it keeps happening.
|
||||
</p>
|
||||
<p className="max-w-lg truncate text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => useTabStore.getState().reloadActiveTab()}
|
||||
>
|
||||
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Reload tab
|
||||
</Button>
|
||||
{safeRoute ? (
|
||||
<Button type="button" variant="outline" onClick={() => navigate(safeRoute, { replace: true })}>
|
||||
Go to issues
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => useTabStore.getState().closeActiveTab()}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Close tab
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("feedback", {
|
||||
initialMessage: report,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Report error
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { name: string; message: string; stack?: string } {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || "Error",
|
||||
message: error.message || "Unknown route error",
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return { name: "Error", message: error };
|
||||
}
|
||||
return { name: "Error", message: "Unknown route error", stack: safeJson(error) };
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
@@ -14,9 +13,8 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
// Render errors bubble to the root route errorElement (DesktopRouteErrorPage),
|
||||
// which contains the crash inside the tab content pane. No page-level boundary
|
||||
// here — a whole-page wrapper duplicates the route-level error UI.
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/vie
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
import { DesktopRouteErrorPage } from "./components/route-error-page";
|
||||
|
||||
/**
|
||||
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
|
||||
@@ -109,6 +109,7 @@ function PageShell() {
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
errorElement: <DesktopRouteErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: null },
|
||||
{
|
||||
@@ -118,11 +119,7 @@ export const appRoutes: RouteObject[] = [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{
|
||||
path: "issues",
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
element: <IssuesPage />,
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -86,6 +86,16 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Recreate the active tab's router at the same path after a route-level crash. */
|
||||
reloadActiveTab: () => void;
|
||||
/**
|
||||
* Close the active tab. The always-safe escape from a route-level crash:
|
||||
* unlike reloadActiveTab (recreates the same crashing path) or navigating
|
||||
* to a "safe" route (which may itself be the route that crashed), closing
|
||||
* destroys the crashing router entirely and falls back to a sibling tab
|
||||
* (or a reseeded default if it was the last tab).
|
||||
*/
|
||||
closeActiveTab: () => void;
|
||||
/**
|
||||
* Reorder within the active workspace's group only. Clamped so a tab can
|
||||
* never cross the pinned / unpinned boundary — a drag that would move a
|
||||
@@ -475,6 +485,38 @@ export const useTabStore = create<TabStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
reloadActiveTab() {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
|
||||
if (index < 0) return;
|
||||
const current = group.tabs[index];
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = {
|
||||
...current,
|
||||
router: createTabRouter(current.path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
window.setTimeout(() => current.router.dispose(), 0);
|
||||
},
|
||||
|
||||
closeActiveTab() {
|
||||
const { activeWorkspaceSlug, byWorkspace, closeTab } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
closeTab(group.activeTabId);
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
|
||||
96
packages/views/modals/feedback.test.tsx
Normal file
96
packages/views/modals/feedback.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
|
||||
let storedDraftMessage = "saved draft";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { changeLanguage: vi.fn() } }),
|
||||
Trans: ({ children }: { children: any }) => children,
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (selector: (resources: any) => string) =>
|
||||
selector({
|
||||
feedback: {
|
||||
title: "Feedback",
|
||||
github_hint_prefix: "Prefer GitHub? ",
|
||||
github_hint_link: "Open an issue",
|
||||
placeholder: "Tell us what happened",
|
||||
toast_uploading: "Uploading",
|
||||
toast_too_long: "Too long",
|
||||
toast_sent: "Sent",
|
||||
toast_failed: "Failed",
|
||||
sending: "Sending",
|
||||
send: "Send",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ id: "ws1" }) }));
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@multica/core/api", () => ({ api: {} }));
|
||||
vi.mock("@multica/core/analytics", () => ({ captureFeedbackOpened: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }));
|
||||
vi.mock("@multica/core/platform", () => ({
|
||||
formatShortcut: () => "⌘↵",
|
||||
modKey: "mod",
|
||||
enterKey: "enter",
|
||||
}));
|
||||
vi.mock("@multica/core/feedback", () => ({
|
||||
useCreateFeedback: () => ({ isPending: false, mutateAsync: vi.fn() }),
|
||||
useFeedbackDraftStore: (selector: any) =>
|
||||
selector({ draft: { message: storedDraftMessage }, setDraft: vi.fn(), clearDraft: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../editor", () => {
|
||||
const ContentEditor = forwardRef(({ defaultValue }: any, ref) => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
hasActiveUploads: () => false,
|
||||
getMarkdown: () => defaultValue,
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return <textarea aria-label="feedback editor" defaultValue={defaultValue} />;
|
||||
});
|
||||
ContentEditor.displayName = "MockContentEditor";
|
||||
return {
|
||||
ContentEditor,
|
||||
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
|
||||
FileDropOverlay: () => null,
|
||||
FileUploadButton: () => <button type="button">Upload</button>,
|
||||
};
|
||||
});
|
||||
|
||||
import { FeedbackModal } from "./feedback";
|
||||
|
||||
describe("FeedbackModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses a crash-report initialMessage when there is no saved draft", () => {
|
||||
storedDraftMessage = "";
|
||||
|
||||
render(<FeedbackModal onClose={vi.fn()} initialMessage="kind: desktop_route_error" />);
|
||||
|
||||
expect(screen.getByLabelText("feedback editor")).toHaveValue("kind: desktop_route_error");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing feedback draft when crash report context is provided", () => {
|
||||
storedDraftMessage = "saved draft";
|
||||
|
||||
render(<FeedbackModal onClose={vi.fn()} initialMessage="kind: desktop_route_error" />);
|
||||
|
||||
expect(screen.getByLabelText("feedback editor")).toHaveValue(
|
||||
"saved draft\n\n---\n\nkind: desktop_route_error",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,28 @@ import { formatShortcut, modKey, enterKey } from "@multica/core/platform";
|
||||
|
||||
const MAX_MESSAGE_LEN = 10000;
|
||||
|
||||
export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
function composeFeedbackInitialMessage(draftMessage: string, incomingInitialMessage: string) {
|
||||
const draft = draftMessage.trim();
|
||||
const incoming = incomingInitialMessage.trim();
|
||||
if (!incoming) return draftMessage;
|
||||
if (!draft) return incomingInitialMessage;
|
||||
if (draft.includes(incoming)) return draftMessage;
|
||||
return `${draftMessage}
|
||||
|
||||
---
|
||||
|
||||
${incomingInitialMessage}`;
|
||||
}
|
||||
|
||||
export function FeedbackModal({
|
||||
onClose,
|
||||
data,
|
||||
initialMessage,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
initialMessage?: string;
|
||||
}) {
|
||||
const { t } = useT("modals");
|
||||
const workspace = useCurrentWorkspace();
|
||||
const draft = useFeedbackDraftStore((s) => s.draft);
|
||||
@@ -34,7 +55,10 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
const clearDraft = useFeedbackDraftStore((s) => s.clearDraft);
|
||||
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [message, setMessage] = useState(draft.message);
|
||||
const incomingInitialMessage =
|
||||
initialMessage ?? (typeof data?.initialMessage === "string" ? data.initialMessage : "");
|
||||
const seededMessage = composeFeedbackInitialMessage(draft.message, incomingInitialMessage);
|
||||
const [message, setMessage] = useState(seededMessage);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
@@ -113,7 +137,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={draft.message}
|
||||
defaultValue={seededMessage}
|
||||
placeholder={t(($) => $.feedback.placeholder)}
|
||||
onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }}
|
||||
onUploadFile={uploadWithToast}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ModalRegistry() {
|
||||
case "create-squad":
|
||||
return <CreateSquadModal onClose={close} />;
|
||||
case "feedback":
|
||||
return <FeedbackModal onClose={close} />;
|
||||
return <FeedbackModal onClose={close} data={data} />;
|
||||
case "issue-set-parent":
|
||||
return <SetParentIssueModal onClose={close} data={data} />;
|
||||
case "issue-add-child":
|
||||
|
||||
Reference in New Issue
Block a user