Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
166 changes: 166 additions & 0 deletions desktop/src/features/terminal/TerminalBootstrap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -137,6 +139,7 @@ afterEach(async () => {
calls.length = 0;
canvasWidth = 840;
attachResolver = null;
attachRejection = null;
deferResizes = false;
deferClose = false;
closeResolver = null;
Expand Down Expand Up @@ -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();
});
47 changes: 46 additions & 1 deletion desktop/src/features/terminal/TerminalBootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,9 +88,12 @@ export function TerminalBootstrap({
new WeakMap<TerminalConnection, TerminalViewportSize>(),
);
const closedSessionKeysRef = React.useRef(new Set<string>());
const failedAttachContextRef = React.useRef<TerminalContext | null>(null);
const attachRetryContextRef = React.useRef<TerminalContext | null>(null);
const [sessions, setSessions] = React.useState<Session[]>([]);
const [activeKey, setActiveKey] = React.useState<string | null>(null);
const [available, setAvailable] = React.useState(() => isTauri());
const [attachError, setAttachError] = React.useState<string | null>(null);
const panel = useTerminalPanel();
const [renderedMode, setRenderedMode] = React.useState<
"docked" | "maximized"
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -258,6 +281,7 @@ export function TerminalBootstrap({
})
.catch((error) => {
if (closedSessionKeysRef.current.delete(key)) return;
failedAttachContextRef.current = spawnContext;
removeSession(key);
fail(error);
});
Expand All @@ -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);
}, [
Expand Down Expand Up @@ -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 (
<TerminalSubstrate
bracketedPaste={active?.frame?.bracketedPaste ?? false}
channelName={active?.context.channelName ?? channelName}
enabled={available && Boolean(context)}
mode={renderedMode}
notice={notice}
visible={panelVisible}
onHide={() => setTerminalPanelMode("closed")}
onModeChange={setTerminalPanelMode}
Expand Down
26 changes: 26 additions & 0 deletions desktop/src/features/terminal/TerminalSubstrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -85,6 +92,7 @@ export function TerminalSubstrate({
focusReportingEnabled,
enabled = true,
mode = "docked",
notice = null,
visible = true,
onHide = NOOP,
onModeChange = NOOP,
Expand Down Expand Up @@ -758,6 +766,24 @@ export function TerminalSubstrate({
{welcomeVisible && banner ? (
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
) : null}
{notice ? (
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 px-6 text-center"
data-testid="terminal-notice"
role="status"
>
<p className="max-w-md text-sm opacity-80">{notice.message}</p>
{notice.action ? (
<button
className="buzz-terminal-designator rounded border border-current px-3 py-1 text-xs"
onClick={notice.action.onAction}
type="button"
>
{notice.action.label}
</button>
) : null}
</div>
) : null}
<textarea
aria-label="Terminal input"
autoCapitalize="off"
Expand Down
Loading