From cbd1fa639ddb55c1994fbac8cf95cf40da9716e8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 23 Sep 2026 11:40:09 +0200 Subject: [PATCH] fix(tmux): merge-time fixes for the exited-agent report (#466) - docs/wiki/The-Dashboard.md: the tab-appearance table gains the exited state (muted dot plus an `exited (137)` badge) and explains the bare `exited` variant. - The detailed sidebar and rail no longer pair the muted dot with an "idle" pill: an exited session's pill reads "exited" (neutral styling) and its since stamp measures from the observed exit. This is a label override on the row model, not a new state, so SESSION_ACTIVITY_RANK and the home screen order are untouched, and a pending alert still keeps its own pill. The row signature includes the flag so the incremental path repaints it. - The exited badge is aria-hidden like its sibling badges, and the exit is appended to the tab's aria-label in both render paths through one helper. - test/tmux-manager.test.ts re-adds the junk-trailing-field parser case against parsePaneRows. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/wiki/The-Dashboard.md | 1 + src/web/public/app.js | 40 +++++++++++--- src/web/public/styles.css | 9 +++ test/session-pane-exit-ui.test.ts | 91 ++++++++++++++++++++++++++++++- test/session-sidebar-ux.test.ts | 4 +- test/tmux-manager.test.ts | 5 ++ 6 files changed, 140 insertions(+), 10 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 3b59f1ac..cf1f415e 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -48,6 +48,7 @@ One tab per session, in your order, and that order syncs across your devices. | Yellow tab, blinking | The agent is waiting for input from you. | | Red tab, blinking | A question or permission prompt is blocking the session. | | No dot | The session is not running. | +| Muted grey dot plus an `exited (137)` badge | The agent inside the pane has exited, with that exit code (or `exited (signal 9)`). A bare `exited` means tmux saw the pane die but did not report how, which is not the same as a clean `exited (0)`. Detailed sidebar and rail rows read `exited` in their pill. | ![Tab alerts](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/tab-alerts-20260815.png) diff --git a/src/web/public/app.js b/src/web/public/app.js index 8d521b68..5bfdfe43 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -360,6 +360,12 @@ function paneExitLabel(paneExit) { return 'exited'; } +// The tab's accessible name, with the exit appended when there is one. Shared +// by the full render and applyPaneExitBadge() so the two cannot disagree. +function paneExitAriaLabel(name, label) { + return label ? `${name} session, agent ${label}` : `${name} session`; +} + // Add, update or remove one tab's exited-agent badge in place. Separate from // the render loop so it can be exercised directly: this is the only path a // session going live-to-exited ever takes, since that transition adds and @@ -371,6 +377,10 @@ function applyPaneExitBadge(tab, paneExit) { // `busy` for an exited pane by design, so without this a green or pulsing dot // sits next to a badge saying the agent is gone. tab.classList.toggle('tab-agent-exited', !!label); + // The tab's aria-label overrides its contents for the accessible name, and the + // badge is aria-hidden like its siblings, so the exit has to ride the label. + const name = tab.querySelector('.tab-name')?.dataset?.fullName; + if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label)); if (!label) { existing?.remove(); return; @@ -378,6 +388,7 @@ function applyPaneExitBadge(tab, paneExit) { if (!existing) { const badge = document.createElement('span'); badge.className = 'tab-exited-badge'; + badge.setAttribute('aria-hidden', 'true'); // Generated status text, like the status pills: it carries data-i18n-skip // rather than a dictionary entry. Without it the translator would rewrite // the badge and the next render pass would rewrite it back, because the @@ -4863,16 +4874,30 @@ class CodemanApp { _sidebarRichRow(id, session) { if (typeof this._mobileOverviewState !== 'function') return null; const state = this._mobileOverviewState(session, this.pendingHooks?.get(id)); + // An exited agent (Ark0N/Codeman#446) overrides the LABEL, never the state: + // `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle + // or busy for an exited pane by design, so without this the muted dot sits + // beside a pill saying "idle". A pending alert still wins, exactly as it + // does for the dot. + const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working'); + const exitAt = exited ? Number(session.paneExit.at) || 0 : 0; return { state, - pill: this._sidebarRichPillLabel(state), + exited, + pill: exited ? 'exited' : this._sidebarRichPillLabel(state), // What the pane's own footer says is still running in the background ("1 monitor", // "2 shells"). A row that has one went quiet because the agent is waiting for that, // which is a different thing from waiting for the user — so it rides BESIDE the // state pill and never replaces it. watching: typeof session.watching === 'string' ? session.watching : '', createdAt: Number(session.createdAt) || 0, - since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null, + since: exitAt + ? { key: 'exited', at: exitAt } + : exited + ? null + : this._mobileOverviewSince + ? this._mobileOverviewSince(state, session) + : null, }; } @@ -4905,7 +4930,8 @@ class CodemanApp { parts.push(''); parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since')); } - parts.push(`${escapeHtml(row.pill)}`); + const pillMod = row.exited ? 'exited' : row.state; + parts.push(`${escapeHtml(row.pill)}`); // The word is duplicated from mobile-overview.js for the same reason the pill labels // above are: it is one word, and this file must render a complete row even when a // stale cached mobile-overview.js has arrived without it. @@ -4958,7 +4984,7 @@ class CodemanApp { // that one goes through escapeHtml() because it is interpolated into markup, and the // browser hands the decoded string back through `dataset`. `watching` is the only // pane-derived value in this signature, which is why it is the only one escaped there. - const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`; + const sig = `${row.state}${row.exited ? '+exited' : ''}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`; if (tab.dataset.tabMetaSig === sig) return; tab.dataset.tabMetaSig = sig; tab.dataset.tabState = row.state; @@ -5582,7 +5608,7 @@ class CodemanApp { const richMeta = this._sidebarRichMetaHTML(richRow); const richClass = richRow ? ` tab-state-${richRow.state}` : ''; const richData = richRow - ? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"` + ? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}${richRow.exited ? '+exited' : ''}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"` : ''; // '' whenever the server said nothing about this pane's agent, which covers @@ -5593,7 +5619,7 @@ class CodemanApp { const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - parts.push(`