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..bfcb632ca7 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,166 @@ 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(); +}); + +test("retry reattaches a failed new tab while another session remains", async () => { + const { 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"); + + const view = render( + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: "channel-1", + channelName: "general", + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => assert.equal(view.getAllByRole("tab").length, 1)); + + attachRejection = "second conpty spawn failed"; + fireEvent.click(view.getByLabelText("New Buzz Term tab")); + await waitFor(() => assert.ok(view.getByTestId("terminal-notice"))); + await waitFor(() => assert.equal(view.getAllByRole("tab").length, 1)); + const failedAttachCount = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; + assert.equal(failedAttachCount, 2); + + attachRejection = null; + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Retry" })); + }); + + await waitFor(() => + assert.equal( + calls.filter(({ command }) => command === "terminal_attach").length, + failedAttachCount + 1, + ), + ); + await waitFor(() => assert.equal(view.getAllByRole("tab").length, 2)); + await waitFor(() => + assert.equal(view.queryByTestId("terminal-notice"), null), + ); + view.unmount(); +}); + +test("retry waits for the channel whose attachment failed", async () => { + const { 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"); + + const tree = (channelId, channelName) => + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId, + channelName, + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ); + const attachCount = (channelId) => + calls.filter( + ({ command, args }) => + command === "terminal_attach" && args.request.channelId === channelId, + ).length; + + const view = render(tree("channel-a", "alpha")); + await waitFor(() => assert.equal(attachCount("channel-a"), 1)); + + attachRejection = "channel-b conpty spawn failed"; + view.rerender(tree("channel-b", "beta")); + await waitFor(() => assert.ok(view.getByTestId("terminal-notice"))); + assert.equal(attachCount("channel-b"), 1); + const failedAttachCount = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; + + view.rerender(tree("channel-a", "alpha")); + attachRejection = null; + await act(async () => { + fireEvent.click(view.getByRole("button", { name: "Retry" })); + }); + await waitFor(() => + assert.equal(view.queryByTestId("terminal-notice"), null), + ); + + assert.equal(attachCount("channel-a"), 1); + assert.equal( + calls.filter(({ command }) => command === "terminal_attach").length, + failedAttachCount, + ); + + view.rerender(tree("channel-b", "beta")); + await waitFor(() => assert.equal(attachCount("channel-b"), 2)); + view.unmount(); +}); diff --git a/desktop/src/features/terminal/TerminalBootstrap.tsx b/desktop/src/features/terminal/TerminalBootstrap.tsx index b86dc89845..1b101bf4df 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.tsx +++ b/desktop/src/features/terminal/TerminalBootstrap.tsx @@ -47,6 +47,15 @@ function report(error: unknown) { console.error("terminal session failed", error); } +function isSameTerminalContext(left: TerminalContext, right: TerminalContext) { + return ( + left.channelId === right.channelId && + left.threadId === right.threadId && + left.npub === right.npub && + left.relayUrl === right.relayUrl + ); +} + export function TerminalBootstrap({ channelId, channelName, @@ -79,9 +88,12 @@ export function TerminalBootstrap({ new WeakMap(), ); const closedSessionKeysRef = React.useRef(new Set()); + const failedAttachContextRef = React.useRef(null); + const attachRetryContextRef = React.useRef(null); 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 +181,20 @@ 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(() => { + const nextAvailable = isTauri(); + attachRetryContextRef.current = nextAvailable + ? failedAttachContextRef.current + : null; + failedAttachContextRef.current = null; + setAttachError(null); + setAvailable(nextAvailable); + }, []); + const removeSession = React.useCallback((key: string) => { setSessions((current) => current.filter((session) => session.key !== key)); setActiveKey((current) => { @@ -258,6 +281,7 @@ export function TerminalBootstrap({ }) .catch((error) => { if (closedSessionKeysRef.current.delete(key)) return; + failedAttachContextRef.current = spawnContext; removeSession(key); fail(error); }); @@ -282,7 +306,11 @@ export function TerminalBootstrap({ React.useEffect(() => { if (panel.mode === "closed" || !available || !context) return; - if (channelSessions.length === 0) createSession(); + const retryContext = attachRetryContextRef.current; + if (retryContext && isSameTerminalContext(retryContext, context)) { + attachRetryContextRef.current = null; + createSession(); + } else if (channelSessions.length === 0) createSession(); else if (!channelSessions.some((session) => session.key === activeKey)) setActiveKey(channelSessions.at(-1)?.key ?? null); }, [ @@ -358,12 +386,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}