Merge pull request 'Stop one tab's status banner from speaking for all the others' (#10) from claude/status-bar-tab-isolation-caa52b into main
ci / build and test (push) Failing after 8s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Failing after 7s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-08-12 09:37:55 +00:00
+74 -11
View File
@@ -84,14 +84,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root');
const statusBanner = document.getElementById('status');
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
const sessions = new Map();
/** @type {WebSocket | null} */
let socket = null;
function setStatus(text) {
statusBanner.textContent = text ?? '';
/** Whose pane is showing, or null before there is one — see activate(). */
let activeSessionId = null;
/*
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
There is one #status element for the whole page, because there is one page for every terminal: the
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
two sources that are not the same size, and the difference is the whole of this.
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
reconnecting" is true of whatever is on screen and true of the panes behind it.
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
nothing whatever about the others — so it is held on the session and drawn only while that session's
pane is the one showing. Written straight into the shared element, which is what this used to do, it
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
The socket's half wins when both have something to say: a page whose socket is down is not showing
live output on any pane, which makes what became of one session the less urgent of the two.
*/
let transportStatus = statusBanner.textContent ?? '';
function renderStatus() {
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
statusBanner.textContent = transportStatus || notice;
}
/** Says something about the socket, which every pane shares. */
function setTransportStatus(text) {
transportStatus = text ?? '';
renderStatus();
}
/** Records what became of one session, to be drawn only while that session's pane is showing. */
function setSessionNotice(sessionId, text) {
const session = sessions.get(sessionId);
if (!session) {
return;
}
session.notice = text ?? '';
renderStatus();
}
/** Builds a frame: opcode, big-endian session id, then payload. */
@@ -292,7 +335,7 @@ function createSession(sessionId) {
term.onResize(() => sendResize(sessionId, term, pane));
const session = { term, fit, pane };
const session = { term, fit, pane, notice: '' };
sessions.set(sessionId, session);
activate(sessionId);
@@ -306,6 +349,11 @@ function activate(sessionId) {
session.pane.dataset.active = String(id === sessionId);
}
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
// session that was showing had to say for its own, which is the point of holding it per session.
activeSessionId = sessionId;
renderStatus();
const active = sessions.get(sessionId);
if (active) {
active.term.focus();
@@ -373,7 +421,10 @@ function handleFrame(buffer) {
session.term.write(REPLAY_BANNER);
}
setStatus('');
// This session's own line, and only this one's: a session that is open has nothing to say about
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
// before any frame can arrive.
setSessionNotice(sessionId, '');
break;
}
@@ -426,7 +477,14 @@ function handleFrame(buffer) {
session.pane.remove();
sessions.delete(sessionId);
setStatus('');
// The notice went with the session record it was held on, but the page can still be pointing at
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
// closed tab while the host decides which pane to show next.
if (activeSessionId === sessionId) {
activeSessionId = null;
}
renderStatus();
break;
}
@@ -503,14 +561,19 @@ function handleFrame(buffer) {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
if (session) {
if (!session) {
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
// the banner anyway, which printed one session's ending underneath whichever pane happened to
// be showing at the time.
break;
}
// The pane and its scrollback stay. The user was probably reading the last thing the
// remote said, and that is usually why the session ended.
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
session.term.options.cursorBlink = false;
}
setStatus(reason);
setSessionNotice(sessionId, reason);
break;
}
@@ -535,7 +598,7 @@ function scheduleReconnect() {
return;
}
setStatus('Reconnecting the terminal view…');
setTransportStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
@@ -555,7 +618,7 @@ function connect() {
socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => {
setStatus('');
setTransportStatus('');
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
// attempts within one outage, reset once the outage is actually over.