From d4a1f13cf12bb791210da6c0069af3ee27946b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 13:14:23 +0000 Subject: [PATCH] Terminal: a shell that ends while its panel is shown again is not replaced Showing the panel connects a frame later (the panel needs its width first). A shell that ended before that frame -- reopened and closed by the server within one frame, as headless CI's slow frames made happen -- was quietly replaced by a new one, against "no reconnect of its own". The frame now connects only if no shell was open as the showing began. The cause of the B3 failures on GitHub since 94e903e; a test makes it happen every time. Co-Authored-By: Claude Opus 5.5 --- tests/ui/run.mjs | 19 +++++++++++++++++++ ui/js/terminal.js | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 2c434a8..b8c3751 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -529,6 +529,25 @@ test("B3: an open agent chat opens its own shell from the header; Send to chat f assert.deepEqual(p.errors, []) }) +test("B3: a shell that ends while its panel is being shown again is not replaced by a new one", async () => { + const p = await page(b3()) + await openChatRow(p, "chat2") + await p.click('#chat-tools [data-terminal-toggle]') + await p.waitForFunction(() => window.__mock.terminals().length === 1) + await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "Connected.") + await p.click("#terminal-close") + // Shown again, and the shell ends before the panel has drawn its first frame. + await p.evaluate(() => { + document.querySelector("#chat-tools [data-terminal-toggle]").click() + window.__mock.shell("control", { t: "closed", message: "The shell exited." }) + }) + await p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))) + await p.waitForTimeout(200) + assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "no new shell in its place") + assert.equal(await p.textContent("#terminal-message"), "The shell exited.") + assert.deepEqual(p.errors, []) +}) + test("B3: the terminal and the activity panel take turns in the panel column", async () => { const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } })) await openChatRow(p, "chat2") diff --git a/ui/js/terminal.js b/ui/js/terminal.js index 93e41ab..f36c24c 100644 --- a/ui/js/terminal.js +++ b/ui/js/terminal.js @@ -280,6 +280,9 @@ export async function showTerminal(on) { ctx.layout(on) markOpen() if (!on) return + // Whether this showing is to open a shell: none was open as it began. One that ends meanwhile + // (before the frame below) is said, not quietly replaced. + const wanted = !session try { await load() } catch { @@ -291,7 +294,7 @@ export async function showTerminal(on) { // A frame later: the panel has only just been shown and has no width to fit to yet. requestAnimationFrame(() => { refit() - connect() + if (wanted && terminalOpen()) connect() term.focus() }) }