From e2f422e4876066ff57174a4169eeb6ee28345cf1 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Sun, 9 Aug 2026 01:02:21 -0300 Subject: [PATCH 1/3] fix(desktop): surface Buzz Term attach failures instead of a blank panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An attach failure removed the tab and set available=false with only a console.error — unreachable in a production build, which has no devtools and, on Windows, no log file. The user got a bare gray panel with a dead + button and nothing to report (#4930). Render the failure in the viewport instead: 'Terminal unavailable: ' with a Retry action, carrying the backend's own message rather than a generic string. On Windows before #5425 that reads 'CreateProcessW "cmd.exe" in cwd None failed ... (os error 2)' — the error the panel already had and was throwing away. Retry clears it and re-enables the auto-create path. No notice for a missing channel context: the Cmd/Ctrl+J toggle refuses to open the panel without one, and an effect closes the panel if the channel goes away, so such a notice could only flash during navigation. Covered by TerminalBootstrap unit tests (notice rendering, and retry re-attaching after a failed attach) and a Playwright screenshot spec that drives the real attach-failure path end to end via the mock bridge, which does not implement terminal_attach. Co-Authored-By: Claude Opus 5 Signed-off-by: Glucksberg --- desktop/playwright.config.ts | 1 + .../terminal/TerminalBootstrap.test.mjs | 59 +++++++++++++++++++ .../features/terminal/TerminalBootstrap.tsx | 24 ++++++++ .../features/terminal/TerminalSubstrate.tsx | 26 ++++++++ .../e2e/term-empty-states-screenshots.spec.ts | 54 +++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 desktop/tests/e2e/term-empty-states-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..a442bf8352 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/term-empty-states-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src/features/terminal/TerminalBootstrap.test.mjs b/desktop/src/features/terminal/TerminalBootstrap.test.mjs index 5cc7c48e85..f6bf631b52 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.test.mjs +++ b/desktop/src/features/terminal/TerminalBootstrap.test.mjs @@ -18,6 +18,7 @@ let channel; let resizeCallback; let canvasWidth = 840; let attachResolver = null; +let attachRejection = null; let deferResizes = false; let deferClose = false; let closeResolver = null; @@ -84,6 +85,7 @@ before(async () => { invoke(command, args) { calls.push({ command, args }); if (command === "terminal_attach") { + if (attachRejection) return Promise.reject(attachRejection); channel = args.onFrame; const sessionNumber = calls.filter( ({ command }) => command === "terminal_attach", @@ -137,6 +139,7 @@ afterEach(async () => { calls.length = 0; canvasWidth = 840; attachResolver = null; + attachRejection = null; deferResizes = false; deferClose = false; closeResolver = null; @@ -653,3 +656,59 @@ test("a non-channel route closes the panel and ignores the terminal shortcut", a assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); view.unmount(); }); + +test("surfaces attach failures as a visible notice with a working retry", async () => { + const { StrictMode, createElement } = await import("react"); + const { act, fireEvent, render, waitFor } = await import( + "@testing-library/react" + ); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + attachRejection = "conpty spawn failed"; + const view = render( + createElement( + StrictMode, + null, + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: "channel-1", + channelName: "general", + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ), + ); + + await waitFor(() => assert.ok(view.getByTestId("terminal-notice"))); + assert.match( + view.getByTestId("terminal-notice").textContent, + /Terminal unavailable: conpty spawn failed/, + ); + + const failedAttachCount = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; + assert.ok(failedAttachCount >= 1); + + attachRejection = null; + const retryButton = view.getByRole("button", { name: "Retry" }); + await act(async () => { + fireEvent.click(retryButton); + }); + + await waitFor(() => + assert.ok( + calls.filter(({ command }) => command === "terminal_attach").length > + failedAttachCount, + ), + ); + await waitFor(() => + assert.equal(view.queryByTestId("terminal-notice"), null), + ); + view.unmount(); +}); diff --git a/desktop/src/features/terminal/TerminalBootstrap.tsx b/desktop/src/features/terminal/TerminalBootstrap.tsx index b86dc89845..4963b41519 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.tsx +++ b/desktop/src/features/terminal/TerminalBootstrap.tsx @@ -82,6 +82,7 @@ export function TerminalBootstrap({ const [sessions, setSessions] = React.useState([]); const [activeKey, setActiveKey] = React.useState(null); const [available, setAvailable] = React.useState(() => isTauri()); + const [attachError, setAttachError] = React.useState(null); const panel = useTerminalPanel(); const [renderedMode, setRenderedMode] = React.useState< "docked" | "maximized" @@ -169,9 +170,15 @@ export function TerminalBootstrap({ const fail = React.useCallback((error: unknown) => { report(error); + setAttachError(error instanceof Error ? error.message : String(error)); setAvailable(false); }, []); + const retry = React.useCallback(() => { + setAttachError(null); + setAvailable(isTauri()); + }, []); + const removeSession = React.useCallback((key: string) => { setSessions((current) => current.filter((session) => session.key !== key)); setActiveKey((current) => { @@ -358,12 +365,29 @@ export function TerminalBootstrap({ if (!panelMounted) return null; + // Silent gray panels are undiagnosable in production builds — no devtools, + // and on Windows no log file either. An unavailable terminal must say so, + // and say what the backend actually reported. + // + // A missing `context` needs no notice: the Cmd/Ctrl+J toggle refuses to open + // the panel without a channel, and the effect above closes it if the channel + // goes away. Rendering one there would only flash during navigation. + const notice = available + ? null + : { + message: attachError + ? `Terminal unavailable: ${attachError}` + : "Terminal is unavailable in this environment.", + action: isTauri() ? { label: "Retry", onAction: retry } : undefined, + }; + return ( setTerminalPanelMode("closed")} onModeChange={setTerminalPanelMode} diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index d8aaee2e2d..f56d63cc00 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -37,6 +37,12 @@ export type TerminalSessionTab = { active: boolean; }; +/** Centered viewport message shown when no session can render. */ +export type TerminalNotice = { + message: string; + action?: { label: string; onAction: () => void }; +}; + type TerminalSubstrateProps = { channelName: string | null; frame?: TerminalFrame; @@ -46,6 +52,7 @@ type TerminalSubstrateProps = { focusReportingEnabled: boolean; enabled?: boolean; mode?: "docked" | "maximized"; + notice?: TerminalNotice | null; visible?: boolean; onHide?: () => void; onModeChange?: (mode: "docked" | "maximized") => void; @@ -85,6 +92,7 @@ export function TerminalSubstrate({ focusReportingEnabled, enabled = true, mode = "docked", + notice = null, visible = true, onHide = NOOP, onModeChange = NOOP, @@ -758,6 +766,24 @@ export function TerminalSubstrate({ {welcomeVisible && banner ? ( ) : null} + {notice ? ( +
+

{notice.message}

+ {notice.action ? ( + + ) : null} +
+ ) : null}