Skip to content

Commit 741468c

Browse files
authored
Give every inline error Copy details and, where it helps, Retry (#269)
Turn failures got Retry, a fix and Copy details in #104, but about two dozen other error surfaces still showed bare text. Copying it lost where and when it happened, and most offered no way to run the action again. A shared ErrorNotice now renders those errors: the message, Retry when the failed action can safely run again, a fix when there is a better remedy, Dismiss where the surface had one, and Copy details. The copied report (shared/error-report.ts) names where and when it happened, quotes the message, and lists the surface's context. Main appends the app version, Electron version and platform through clipboard:write's new appInfo flag. Streamer Mode redacts the shown message and the whole report. Retry repeats exactly the action that failed: restart the terminal, reload a diff, file or preview, re-run a companion, routine, plugin or browser action, save Settings again, re-run an approval preview, reload history, comments or agent access. It is left out where repeating cannot help: a rejected browser address, a skill in the wrong scope, lists that already refresh every few seconds, the learning reset (its text says to reopen Settings first), and Images, whose errors have no single action. e2e: the Settings save failure test now copies the report and retries the save; two selectors follow the moved Retry and message elements. Coven-Harness: Claude Code / claude-opus-5-5
1 parent 45491e9 commit 741468c

32 files changed

Lines changed: 295 additions & 77 deletions

‎apps/desktop/e2e/errors.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ test("the model-list Retry requests the catalogue again and clears the error", a
194194
await expect(tid(page, "composer-input")).toBeVisible();
195195
await page.keyboard.press("Meta+n");
196196
await expect(tid(page, "draft-view")).toBeVisible();
197-
await expect(tid(page, "models-retry")).toBeVisible();
198-
await tid(page, "models-retry").click();
199-
await expect(tid(page, "models-retry")).toHaveCount(0);
197+
await expect(tid(tid(page, "models-error"), "error-retry")).toBeVisible();
198+
await tid(tid(page, "models-error"), "error-retry").click();
199+
await expect(tid(tid(page, "models-error"), "error-retry")).toHaveCount(0);
200200
await expect(tid(page, "model-picker")).toContainText("Recovered model");
201201
});

‎apps/desktop/e2e/settings-transactions.spec.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ test("saving freezes the draft until persistence resolves and keeps it available
4747
await expect(dialog).toHaveAttribute("aria-busy", "false");
4848
await expect(tid(dialog, "settings-content")).not.toHaveAttribute("inert", "");
4949
await expect(mode).toHaveValue("agent");
50+
51+
// Copy details puts a report on the clipboard: where, the message, and the app build main appends.
52+
await tid(tid(page, "settings-save-error"), "error-copy").click();
53+
await expect(tid(tid(page, "settings-save-error"), "error-copy")).toHaveText("Copied");
54+
const report = await app.evaluate(({ clipboard }) => clipboard.readText());
55+
expect(report).toContain("Modex error: Settings · save");
56+
expect(report).toContain("fake persistence failure");
57+
expect(report).toMatch(/App: Modex \d+\.\d+\.\d+/);
58+
// Retry save runs the same save again with the draft intact.
59+
await tid(tid(page, "settings-save-error"), "error-retry").click();
60+
await expect(dialog).toHaveAttribute("aria-busy", "true");
61+
await app.evaluate(() => (globalThis as any).__modexRejectSettingsSave());
62+
await expect(tid(page, "settings-save-error")).toContainText("fake persistence failure");
63+
await expect(mode).toHaveValue("agent");
5064
await dialog.getByRole("button", { name: "Cancel" }).click();
5165
});
5266

@@ -299,6 +313,6 @@ test("an empty save failure message keeps one plain retry instruction", async ()
299313
await tid(page, "open-settings").click();
300314
const dialog = tid(page, "settings");
301315
await dialog.getByRole("button", { name: "Save", exact: true }).click();
302-
await expect(tid(dialog, "settings-save-error")).toHaveText("Could not save settings. Your draft is still here; retry or cancel.");
316+
await expect(tid(tid(dialog, "settings-save-error"), "error-text")).toHaveText("Could not save settings. Your draft is still here; retry or cancel.");
303317
await dialog.getByRole("button", { name: "Cancel" }).click();
304318
});

‎apps/desktop/src/main/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { imageFixtureBackend } from "./engine/images/fixture.js";
22
import { Media } from "./media.js";
33
import { IMAGE_SCHEME } from "../shared/images.js";
4+
import { appInfoLine } from "../shared/error-report.js";
45
import { NativeDrafts } from "./engine/native-drafts.js";
56
import { OwnerRemoval } from "./engine/owner-removal.js";
67
import type { DraftRemovalScope } from "../shared/native-drafts.js";
@@ -907,7 +908,10 @@ handle("shell:openPath", async ({ path: p }) => {
907908
if (err) throw new Error(err);
908909
});
909910
handle("shell:openTerminal", ({ path: p }) => openTerminal(p));
910-
handle("clipboard:write", ({ text }) => { clipboard.writeText(String(text).slice(0, 200_000)); });
911+
handle("clipboard:write", ({ text, appInfo }) => {
912+
const footer = appInfo ? `\n\n${appInfoLine({ version: app.getVersion(), electron: process.versions.electron, platform: process.platform, arch: process.arch })}` : "";
913+
clipboard.writeText((String(text) + footer).slice(0, 200_000));
914+
});
911915
handle("terminal:open", ({ threadId, cols, rows }) => terminals.open(threadId, cols, rows));
912916
handle("terminal:write", ({ threadId, sessionId, data }) => terminals.write(threadId, sessionId, data));
913917
handle("terminal:resize", ({ threadId, sessionId, cols, rows }) => terminals.resize(threadId, sessionId, cols, rows));

‎apps/desktop/src/renderer/components/AnalyticsView.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useLayoutEffect, useRef, useState } from "react";
22
import { useStreamerMode } from "../streamer";
33
import { bridge } from "../bridge";
4+
import { ErrorNotice } from "./ui/ErrorNotice";
45

56
/**
67
* The Analytics surface. The dashboard is its own document in a native view over this canvas, so its
@@ -11,6 +12,8 @@ export function AnalyticsView({ active }: { active: boolean }) {
1112
const streamerMode = useStreamerMode();
1213
const canvas = useRef<HTMLDivElement>(null);
1314
const [error, setError] = useState<string | null>(null);
15+
// Bumped by the error's Retry: shows the dashboard again.
16+
const [attempt, setAttempt] = useState(0);
1417

1518
useLayoutEffect(() => {
1619
let alive = true;
@@ -31,11 +34,11 @@ export function AnalyticsView({ active }: { active: boolean }) {
3134
mutations.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-panel-resizing"] });
3235
window.addEventListener("resize", update);
3336
return () => { alive = false; resize.disconnect(); mutations.disconnect(); window.removeEventListener("resize", update); void bridge.invoke("analytics:show", {}).catch(() => {}); };
34-
}, [active, streamerMode]);
37+
}, [active, streamerMode, attempt]);
3538

3639
return <section className="analytics-surface" data-testid="analytics" aria-label="Analytics" hidden={!active}>
3740
{streamerMode && <p className="analytics-note" role="status">Analytics is hidden while Streamer Mode is on.</p>}
38-
{error && <p className="analytics-note" role="alert">{error}</p>}
41+
{error && <ErrorNotice className="analytics-note" testId="analytics-error" where="Analytics" message={error} onRetry={() => { setError(null); setAttempt((n) => n + 1); }} />}
3942
<div ref={canvas} className="analytics-canvas" data-testid="analytics-canvas" />
4043
</section>;
4144
}

‎apps/desktop/src/renderer/components/ApprovalRules.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { SecretOption, SecretInput } from "../streamer";
22
import { useEffect, useState } from "react";
33
import type { ApprovalPreview, ApprovalRule, Mode, Project, RoutingStatus, RuleDecision } from "../../shared/types";
44
import { bridge } from "../bridge";
5+
import { ErrorNotice } from "./ui/ErrorNotice";
56

67
interface Props {
78
rules: ApprovalRule[];
@@ -77,7 +78,7 @@ export function ApprovalRules({ rules, projects, currentProjectId, gateEnabled,
7778
<label className="check"><SecretInput type="checkbox" checked={escalation} onChange={(e) => setEscalation(e.target.checked)} /><span>Requests additional sandbox permissions</span></label>
7879
<button type="button" className="btn small" disabled={!projectId || !tool.trim() || !title.trim() || shown?.pending} onClick={() => void run()}>{shown?.pending ? "Checking…" : "Try it"}</button>
7980
<div aria-live="polite" aria-atomic="true">
80-
{shown?.error && <p className="warn" role="alert">{shown.error}</p>}
81+
{shown?.error && <ErrorNotice className="warn" testId="approval-preview-error" where="Settings · approval rule preview" message={shown.error} onRetry={shown.pending ? undefined : () => void run()} context={{ tool, projectId }} />}
8182
{shown?.value && <PreviewReceipt result={shown.value} />}
8283
</div>
8384
</section>;

‎apps/desktop/src/renderer/components/BrowserToolsDialog.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { useEffect, useRef, useState } from "react";
22
import type { BrowserToolsSnapshot } from "../../shared/browser";
33
import { bridge } from "../bridge";
44
import { Icon } from "./ui/Icon";
5+
import { ErrorNotice } from "./ui/ErrorNotice";
56

67
export function BrowserToolsDialog({ onClose }: { onClose(): void }) {
78
const [state, setState] = useState<BrowserToolsSnapshot | null>(null);
89
const [busy, setBusy] = useState(false);
910
const [error, setError] = useState("");
11+
// The operation behind `error`, for its Retry. Loading errors have none: they mean no desktop host.
12+
const [failed, setFailed] = useState<{ message: string; operation: () => Promise<BrowserToolsSnapshot> } | null>(null);
1013
const [notice, setNotice] = useState("");
1114
const dialog = useRef<HTMLElement>(null);
1215
const pending = useRef(false);
@@ -32,7 +35,11 @@ export function BrowserToolsDialog({ onClose }: { onClose(): void }) {
3235
if (pending.current) return;
3336
pending.current = true; setBusy(true); setError(""); setNotice("");
3437
try { setState(await operation()); setNotice("Reload open browser tabs to apply extension changes."); }
35-
catch (error) { setError((error as Error).message.replace(/^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/, "")); }
38+
catch (error) {
39+
const message = (error as Error).message.replace(/^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/, "");
40+
setError(message);
41+
setFailed({ message, operation });
42+
}
3643
finally { pending.current = false; setBusy(false); }
3744
};
3845
return <div className="modal-backdrop" onMouseDown={event => { if (event.target === event.currentTarget && !pending.current) onClose(); }}>
@@ -57,7 +64,7 @@ export function BrowserToolsDialog({ onClose }: { onClose(): void }) {
5764
<p>Device Touch ID passkeys are unavailable in this release. A signed app alone does not authorize access to their protected keychain group.</p>
5865
<p>For existing 1Password or iCloud passkeys, choose <strong>Page options → Open in system browser</strong>. Your browser keeps that sign-in; its session is separate from Modex.</p>
5966
</section>
60-
{(error || state?.error) && <p className="workspace-error" role="alert">{error || state?.error}</p>}
67+
{(error || state?.error) && <ErrorNotice className="workspace-error" testId="browser-tools-error" where="Browser extensions" message={error || state?.error || ""} onRetry={!busy && error && failed?.message === error ? () => void act(failed.operation) : undefined} />}
6168
{notice && <p role="status">{notice}</p>}
6269
</section>
6370
</div>;

‎apps/desktop/src/renderer/components/CanvasPanel.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Thread } from "../../shared/types";
33
import { canvasFile, type CanvasSnapshot } from "../../shared/canvas";
44
import { bridge } from "../bridge";
55
import { IconButton } from "./ui/IconButton";
6+
import { ErrorNotice } from "./ui/ErrorNotice";
67
import { SecretOption, useStreamerMode } from "../streamer";
78

89
export function CanvasPanel({ id, thread, visible, suspended, full }: { id: string; thread: Thread; visible: boolean; suspended: boolean; full: boolean }) {
@@ -72,6 +73,11 @@ export function CanvasPanel({ id, thread, visible, suspended, full }: { id: stri
7273
window.addEventListener("resize", update);
7374
return () => { resize.disconnect(); mutations.disconnect(); window.removeEventListener("resize", update); void bridge.invoke("canvas:show", { id: null }).catch(() => {}); };
7475
}, [id, visible, suspended, full, error, snapshot?.error, openedPath, selected, streamerMode]);
76+
// After a failed open, reopen the preview from scratch; otherwise ask the open preview to reload.
77+
const reloadCanvas = () => {
78+
if (error) setRevision(r => r + 1);
79+
else reload.current?.();
80+
};
7581
return <section className="workspace-canvas" hidden={!visible} aria-label="Canvas">
7682
<header className="review-toolbar canvas-toolbar">
7783
<label htmlFor={`canvas-file-${id}`}>Canvas</label>
@@ -80,12 +86,10 @@ export function CanvasPanel({ id, thread, visible, suspended, full }: { id: stri
8086
{selected && !paths.includes(selected) && <SecretOption value={selected}>{selected}</SecretOption>}
8187
{paths.map(p => <SecretOption key={p} value={p}>{p}</SecretOption>)}
8288
</select>
83-
<IconButton icon="restart" label="Reload Canvas" disabled={!selected} onClick={() => {
84-
if (error) setRevision(r => r + 1);
85-
else reload.current?.();
86-
}} />
89+
<IconButton icon="restart" label="Reload Canvas" disabled={!selected} onClick={reloadCanvas} />
8790
</header>
88-
{(error || snapshot?.error || listingError) && <p className="workspace-error" role="alert" data-testid="canvas-error">{error || snapshot?.error || listingError}</p>}
91+
{/* A listing error clears itself: the file list is re-read every 2 s. Preview errors reload like the header button. */}
92+
{(error || snapshot?.error || listingError) && <ErrorNotice className="workspace-error" testId="canvas-error" where="Canvas" message={error || snapshot?.error || listingError} onRetry={selected && (error || snapshot?.error) ? reloadCanvas : undefined} retryLabel="Reload preview" context={{ threadId: thread.id, cwd: thread.cwd, file: selected || undefined }} />}
8993
{!selected && <p className="hint pad">Preview an HTML, SVG or Markdown file. Canvas refreshes when the file or its local assets change.</p>}
9094
{snapshot?.loading && <span className="hint canvas-loading" role="status">Loading preview…</span>}
9195
<div className="workspace-guest" ref={host} data-testid="canvas-preview">{streamerMode && <p className="streamer-guest-cover" data-testid="streamer-canvas-cover">Canvas content is covered while Streamer Mode is on.</p>}</div>

‎apps/desktop/src/renderer/components/ChangesPanel.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { bridge } from "../bridge";
55
import { Icon } from "./ui/Icon";
66
import { IconButton } from "./ui/IconButton";
77
import { Menu, MenuItem } from "./ui/Menu";
8+
import { ErrorNotice } from "./ui/ErrorNotice";
89

910
interface Props { visible: boolean; thread: Thread; changes: ChangesSnapshot | null; onRefresh: () => void; onRevert: (path: string) => void }
1011
const compactCount = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
@@ -27,6 +28,8 @@ export function ChangesPanel({ visible, thread, changes, onRefresh, onRevert }:
2728
const closeOptions = () => { setMenu(false); menuRef.current?.focus(); };
2829
useEffect(() => { setExpanded(false); }, [file]);
2930
useEffect(() => { if (!visible) setMenu(false); }, [visible]);
31+
// Bumped by the diff error's Retry: loads the same file's diff again.
32+
const [diffAttempt, setDiffAttempt] = useState(0);
3033
useEffect(() => {
3134
if (!file) return;
3235
let live = true;
@@ -35,7 +38,8 @@ export function ChangesPanel({ visible, thread, changes, onRefresh, onRevert }:
3538
(error: Error) => { if (live) setResult({ file, snapshot: changes, expanded, text: "", error: error.message }); },
3639
);
3740
return () => { live = false; };
38-
}, [file, thread.id, changes, entry?.original, expanded]);
41+
}, [file, thread.id, changes, entry?.original, expanded, diffAttempt]);
42+
const retryDiff = () => { setResult(null); setDiffAttempt((n) => n + 1); };
3943
const totals = (changes?.files ?? []).reduce((acc, f) => ({ a: acc.a + f.additions, d: acc.d + f.deletions }), { a: 0, d: 0 });
4044
const paths = (changes?.files ?? []).filter((f) => f.path.toLowerCase().includes(filter.toLowerCase())).sort((a, b) => Number(a.code === "??") - Number(b.code === "??") || a.path.localeCompare(b.path));
4145
const groups = new Map<string, typeof paths>();
@@ -71,7 +75,7 @@ export function ChangesPanel({ visible, thread, changes, onRefresh, onRevert }:
7175
<div className={`review-body${tree ? "" : " tree-hidden"}`}>
7276
<div className="review-diff" data-testid="changes-diff">
7377
<div className="review-file-heading"><FileIcon path={file ?? ""} /><span title={file ?? ""}><bdi dir="ltr">{file}</bdi></span><span className="stat add">+{entry?.additions ?? 0}</span><span className="stat del">-{entry?.deletions ?? 0}</span></div>
74-
{!diff ? <p className="hint pad" role="status">Loading diff…</p> : diff.error ? <p className="hint pad" role="alert">Could not load diff: {diff.error}</p> : <Diff text={diff.text} wrap={wrap} highlight={highlight} onExpand={() => setExpanded(true)} />}
78+
{!diff ? <p className="hint pad" role="status">Loading diff…</p> : diff.error ? <ErrorNotice className="hint pad" testId="changes-diff-error" where="Changes · diff" message={`Could not load diff: ${diff.error}`} onRetry={retryDiff} context={{ threadId: thread.id, cwd: thread.cwd, path: file, original: entry?.original }} /> : <Diff text={diff.text} wrap={wrap} highlight={highlight} onExpand={() => setExpanded(true)} />}
7579
</div>
7680
{tree && <aside className="review-tree" data-testid="review-tree" aria-label="Changed files">
7781
<label className="review-filter"><Icon name="search" size={13} /><SecretInput ref={filterRef} data-testid="review-filter" aria-label="Filter files" placeholder="Filter files..." value={filter} onChange={(e) => setFilter(e.target.value)} /></label>

‎apps/desktop/src/renderer/components/CompanionDialog.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { useEffect, useRef, useState } from "react";
33
import type { CompanionStatus } from "../../shared/types";
44
import { bridge } from "../bridge";
55
import { Icon } from "./ui/Icon";
6+
import { ErrorNotice } from "./ui/ErrorNotice";
67

78
export function CompanionDialog({ onClose }: { onClose: () => void }) {
89
const streamerMode = useStreamerMode();
910
const [status, setStatus] = useState<CompanionStatus | null>(null);
1011
const [busy, setBusy] = useState(false);
1112
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
1213
const [error, setError] = useState<string | null>(null);
14+
// The action that failed, for the error's Retry. Status errors have none: status refreshes every 2.5 s.
15+
const [failedAction, setFailedAction] = useState<(() => void) | null>(null);
1316
const [copiedLink, setCopiedLink] = useState<string | null>(null);
1417
const endpoint = status?.endpoints?.find((candidate) => candidate.address === selectedAddress) ?? status?.endpoints?.[0];
1518
// Older hosts provide only one code. New hosts offer one code for each live listener.
@@ -33,7 +36,7 @@ export function CompanionDialog({ onClose }: { onClose: () => void }) {
3336
const value = await bridge.invoke("companion:status", undefined);
3437
if (active && request === requestRevision.current) setStatus(value);
3538
} catch (err) {
36-
if (active && request === requestRevision.current) setError((err as Error).message);
39+
if (active && request === requestRevision.current) { setError((err as Error).message); setFailedAction(null); }
3740
}
3841
};
3942
void refresh();
@@ -56,8 +59,9 @@ export function CompanionDialog({ onClose }: { onClose: () => void }) {
5659
actionPending.current = true;
5760
requestRevision.current += 1;
5861
setError(null);
62+
setFailedAction(null);
5963
try { setStatus(await bridge.invoke(channel, undefined)); }
60-
catch (err) { setError((err as Error).message); }
64+
catch (err) { setError((err as Error).message); setFailedAction(() => () => void act(channel)); }
6165
finally { actionPending.current = false; setBusy(false); }
6266
};
6367
const copyLink = async () => {
@@ -66,7 +70,7 @@ export function CompanionDialog({ onClose }: { onClose: () => void }) {
6670
try {
6771
await bridge.invoke("clipboard:write", { text: link });
6872
setCopiedLink(link);
69-
} catch { setError("Could not copy the pairing link. Please try again."); }
73+
} catch { setError("Could not copy the pairing link."); setFailedAction(() => () => void copyLink()); }
7074
};
7175
return (
7276
<div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
@@ -100,7 +104,7 @@ export function CompanionDialog({ onClose }: { onClose: () => void }) {
100104
<span>{status?.enabled ? "A local address is needed to pair your iPhone." : "Turn on the companion to show a private pairing code."}</span>
101105
</div>
102106
)}
103-
{error && <p className="companion-error" role="alert">{error}</p>}
107+
{error && <ErrorNotice className="companion-error" testId="companion-error" where="iPhone companion" message={error} onRetry={failedAction && !busy ? () => { setError(null); failedAction(); } : undefined} context={{ serviceEnabled: status?.enabled }} />}
104108
<div className="companion-actions">
105109
{status?.enabled ? (
106110
<>

0 commit comments

Comments
 (0)