MUL-3239: include route context in desktop recovery prompts

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-06-15 16:50:54 +08:00
committed by GitHub
parent 76c687d39a
commit 93541be975
7 changed files with 169 additions and 5 deletions

View File

@@ -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 {
RENDERER_ROUTE_CONTEXT_CHANNEL,
sanitizeRendererRouteContext,
type RendererRouteContext,
} from "../shared/renderer-route-context";
import {
createElectronReloadPrompt,
installRendererRecoveryHandlers,
@@ -62,6 +67,7 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
let latestRendererRouteContext: RendererRouteContext | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
@@ -166,9 +172,13 @@ function createWindow(): void {
},
});
const window = mainWindow;
latestRendererRouteContext = null;
window.on("closed", () => {
if (mainWindow === window) mainWindow = null;
if (mainWindow === window) {
mainWindow = null;
latestRendererRouteContext = null;
}
});
// Strip Origin header from WebSocket upgrade requests so the server's
@@ -259,6 +269,12 @@ function createWindow(): void {
showReloadPrompt: createElectronReloadPrompt((options) =>
dialog.showMessageBox(window, options),
),
getDiagnosticContext: () => ({
windowUrl: window.webContents.getURL(),
...(latestRendererRouteContext
? { desktopRoute: latestRendererRouteContext }
: {}),
}),
});
installContextMenu(window.webContents);
@@ -404,6 +420,13 @@ if (!gotTheLock) {
event.returnValue = runtimeConfigResult;
});
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
if (!mainWindow || event.sender !== mainWindow.webContents) return;
const sanitized = sanitizeRendererRouteContext(context);
if (!sanitized) return;
latestRendererRouteContext = sanitized;
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.

View File

@@ -83,10 +83,50 @@ describe("installRendererRecoveryHandlers", () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
const desktopRoute = {
surface: "tab",
path: "/acme/issues/MUL-3239",
workspaceSlug: "acme",
tabId: "tab-1",
reportedAt: "2026-06-15T00:00:00.000Z",
};
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => ({
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
}),
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({
kind: "unresponsive",
context: {
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
},
});
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps prompting when diagnostic context collection fails", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => {
throw new Error("diagnostics unavailable");
},
unresponsivePromptDelayMs: 100,
});
@@ -94,7 +134,6 @@ describe("installRendererRecoveryHandlers", () => {
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps dev diagnostics non-prompting", async () => {

View File

@@ -17,6 +17,7 @@ type ReloadPromptResult = "reload" | "dismiss";
type RendererRecoveryOptions = {
isDev: boolean;
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
getDiagnosticContext?: () => Record<string, unknown>;
log?: (tag: string, ...args: unknown[]) => void;
unresponsivePromptDelayMs?: number;
};
@@ -26,11 +27,16 @@ export function installRendererRecoveryHandlers(
{
isDev,
showReloadPrompt,
getDiagnosticContext,
log = defaultDevLog,
unresponsivePromptDelayMs = 1500,
}: RendererRecoveryOptions,
) {
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
...readDiagnosticContext(getDiagnosticContext),
...context,
});
const maybePromptReload = (payload: ReloadPromptPayload) => {
if (isDev) return;
void showReloadPrompt(payload).then((result) => {
@@ -43,14 +49,17 @@ export function installRendererRecoveryHandlers(
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 } });
maybePromptReload({
kind: "render-process-gone",
context: mergeDiagnosticContext({ 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) },
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
});
});
@@ -58,7 +67,10 @@ export function installRendererRecoveryHandlers(
if (isDev || unresponsivePromptTimer) return;
unresponsivePromptTimer = setTimeout(() => {
unresponsivePromptTimer = null;
maybePromptReload({ kind: "unresponsive", context: {} });
maybePromptReload({
kind: "unresponsive",
context: mergeDiagnosticContext({}),
});
}, unresponsivePromptDelayMs);
});
@@ -142,6 +154,17 @@ function defaultDevLog(tag: string, ...args: unknown[]) {
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
}
function readDiagnosticContext(
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
) {
if (!getDiagnosticContext) return {};
try {
return getDiagnosticContext();
} catch {
return {};
}
}
function formatError(error: unknown) {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}

View File

@@ -1,6 +1,7 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -45,6 +46,8 @@ interface DesktopAPI {
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (

View File

@@ -1,6 +1,10 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
type RendererRouteContextInput,
} from "../shared/renderer-route-context";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
@@ -156,6 +160,9 @@ const desktopAPI = {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) =>
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),

View File

@@ -7,6 +7,7 @@ import {
useTabStore,
} from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes,
@@ -90,6 +91,16 @@ export function PageviewTracker() {
const last = lastSurfaceRef.current;
const next = { kind, key, path };
const routeContext: RendererRouteContextInput = {
surface: kind,
path,
};
if (kind === "tab") {
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
routeContext.tabId = activeTabId ?? undefined;
}
reportRendererRouteContext(routeContext);
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
@@ -112,6 +123,13 @@ export function PageviewTracker() {
return null;
}
function reportRendererRouteContext(context: RendererRouteContextInput) {
const desktopAPI = window.desktopAPI as
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
| undefined;
desktopAPI?.setRendererRouteContext?.(context);
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":

View File

@@ -0,0 +1,51 @@
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
export type RendererRouteSurface = "login" | "overlay" | "tab";
export type RendererRouteContextInput = {
surface: RendererRouteSurface;
path: string;
workspaceSlug?: string;
tabId?: string;
};
export type RendererRouteContext = RendererRouteContextInput & {
reportedAt: string;
};
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
export function sanitizeRendererRouteContext(
value: unknown,
reportedAt = new Date(),
): RendererRouteContext | null {
if (!value || typeof value !== "object") return null;
const input = value as Record<string, unknown>;
if (!isRendererRouteSurface(input.surface)) return null;
const path = sanitizeString(input.path);
if (!path) return null;
const workspaceSlug = sanitizeString(input.workspaceSlug);
const tabId = sanitizeString(input.tabId);
return {
surface: input.surface,
path,
...(workspaceSlug ? { workspaceSlug } : {}),
...(tabId ? { tabId } : {}),
reportedAt: reportedAt.toISOString(),
};
}
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
return value === "login" || value === "overlay" || value === "tab";
}
function sanitizeString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
}