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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-23 11:40:27 +02:00
parent 43d4be8eeb
commit cbd1fa639d
6 changed files with 140 additions and 10 deletions
+1
View File
@@ -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. | | Yellow tab, blinking | The agent is waiting for input from you. |
| Red tab, blinking | A question or permission prompt is blocking the session. | | Red tab, blinking | A question or permission prompt is blocking the session. |
| No dot | The session is not running. | | 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) ![Tab alerts](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/tab-alerts-20260815.png)
+33 -7
View File
@@ -360,6 +360,12 @@ function paneExitLabel(paneExit) {
return 'exited'; 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 // 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 // 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 // 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 // `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. // sits next to a badge saying the agent is gone.
tab.classList.toggle('tab-agent-exited', !!label); 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) { if (!label) {
existing?.remove(); existing?.remove();
return; return;
@@ -378,6 +388,7 @@ function applyPaneExitBadge(tab, paneExit) {
if (!existing) { if (!existing) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'tab-exited-badge'; badge.className = 'tab-exited-badge';
badge.setAttribute('aria-hidden', 'true');
// Generated status text, like the status pills: it carries data-i18n-skip // Generated status text, like the status pills: it carries data-i18n-skip
// rather than a dictionary entry. Without it the translator would rewrite // rather than a dictionary entry. Without it the translator would rewrite
// the badge and the next render pass would rewrite it back, because the // the badge and the next render pass would rewrite it back, because the
@@ -4863,16 +4874,30 @@ class CodemanApp {
_sidebarRichRow(id, session) { _sidebarRichRow(id, session) {
if (typeof this._mobileOverviewState !== 'function') return null; if (typeof this._mobileOverviewState !== 'function') return null;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id)); 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 { return {
state, 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", // 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, // "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 // which is a different thing from waiting for the user — so it rides BESIDE the
// state pill and never replaces it. // state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '', watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0, 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('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>'); parts.push('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>');
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since')); parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
} }
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`); const pillMod = row.exited ? 'exited' : row.state;
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(pillMod)}">${escapeHtml(row.pill)}</span>`);
// The word is duplicated from mobile-overview.js for the same reason the pill labels // 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 // 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. // 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 // 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 // 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. // 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; if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig; tab.dataset.tabMetaSig = sig;
tab.dataset.tabState = row.state; tab.dataset.tabState = row.state;
@@ -5582,7 +5608,7 @@ class CodemanApp {
const richMeta = this._sidebarRichMetaHTML(richRow); const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : ''; const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow 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 // '' whenever the server said nothing about this pane's agent, which covers
@@ -5593,7 +5619,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions(); const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`; const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}> parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''} ${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5601,7 +5627,7 @@ class CodemanApp {
<span class="tab-name-row"> <span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''} ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span> <span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip>${escapeHtml(paneExitBadge)}</span>` : ''} ${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''} ${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span> <span class="tab-detached-badge" aria-hidden="true">detached</span>
</span> </span>
+9
View File
@@ -18429,6 +18429,15 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
color: color-mix(in srgb, var(--green) 45%, var(--text-muted)); color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
} }
/* An exited agent (Ark0N/Codeman#446): neutral, like the muted dot beside it.
No green at all, since nothing is running behind this row. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--exited,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--exited {
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
color: var(--text-muted);
}
/* Accent, deliberately none of the three above: a watching session is waiting for /* Accent, deliberately none of the three above: a watching session is waiting for
work it started itself, not for the user, and the red and yellow here are spoken work it started itself, not for the user, and the red and yellow here are spoken
for by sessions that ARE waiting for the user. */ for by sessions that ARE waiting for the user. */
+90 -1
View File
@@ -60,6 +60,7 @@ describe('the exited-agent badge in a tab', () => {
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const source = [ const source = [
appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0], appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0],
appJs.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)?.[0],
appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0], appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0],
].join('\n'); ].join('\n');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>'); const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
@@ -71,7 +72,8 @@ describe('the exited-agent badge in a tab', () => {
const makeTab = () => { const makeTab = () => {
const tab = dom.window.document.createElement('div'); const tab = dom.window.document.createElement('div');
tab.className = 'session-tab'; tab.className = 'session-tab';
tab.innerHTML = '<span class="tab-name">w1-case</span>'; tab.setAttribute('aria-label', 'w1-case session');
tab.innerHTML = '<span class="tab-name" data-full-name="w1-case">w1-case</span>';
return tab; return tab;
}; };
const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) => const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) =>
@@ -96,6 +98,27 @@ describe('the exited-agent badge in a tab', () => {
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true); expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
}); });
it('hides the badge from assistive technology, like its sibling badges', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
expect(badge(tab)?.getAttribute('aria-hidden')).toBe('true');
});
it('carries the exit on the tab accessible name instead, and drops it again', () => {
// The tab's aria-label overrides its contents, so the badge alone would leave
// a screen reader announcing an exited tab exactly like a live one.
const tab = makeTab();
applyPaneExitBadge(tab, { status: 137, at: 1 });
expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (137)');
applyPaneExitBadge(tab, undefined);
expect(tab.getAttribute('aria-label')).toBe('w1-case session');
});
it('builds the full render path accessible name from the same helper', () => {
expect(appJs).toContain('aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"');
expect(appJs).toContain('<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">');
});
it('updates the text in place rather than stacking a second badge', () => { it('updates the text in place rather than stacking a second badge', () => {
const tab = makeTab(); const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 }); applyPaneExitBadge(tab, { status: 0, at: 1 });
@@ -135,6 +158,72 @@ describe('the exited-agent badge in a tab', () => {
}); });
}); });
describe('the rich row pill of an exited session', () => {
// The detailed sidebar and rail classify rows through `_mobileOverviewState()`,
// which reads `status` and knows nothing about the exit, so without an override
// the muted dot sat beside a pill saying "idle".
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const fn = (re: RegExp, name: string) => {
const m = appJs.match(re)?.[0];
if (!m) throw new Error(`${name} not found in app.js`);
return m;
};
type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null };
const host = new Function(
`${fn(/function paneExitLabel\([\s\S]*?\n\}/, 'paneExitLabel')}
return {
${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')},
${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')},
_mobileOverviewState(session, hooks) {
if (hooks && hooks.has('permission_prompt')) return 'needs';
if (hooks && hooks.has('idle_prompt')) return 'waiting';
return session.status === 'busy' ? 'working' : 'idle';
},
_mobileOverviewSince(state, session) {
return { key: state, at: session.lastActivityAt };
},
};`
)() as { pendingHooks?: Map<string, Set<string>>; _sidebarRichRow: (id: string, s: unknown) => Row };
it('says exited, measured from when the exit was observed', () => {
const row = host._sidebarRichRow('s1', { status: 'idle', lastActivityAt: 5, paneExit: { status: 137, at: 42 } });
expect(row.state).toBe('idle');
expect(row.exited).toBe(true);
expect(row.pill).toBe('exited');
expect(row.since).toEqual({ key: 'exited', at: 42 });
});
it('keeps the classified state for sorting, so the home-screen order is unchanged', () => {
const row = host._sidebarRichRow('s1', { status: 'busy', lastActivityAt: 5, paneExit: { at: 42 } });
expect(row.state).toBe('working');
expect(row.pill).toBe('exited');
});
it('lets a pending permission dialog keep its own pill', () => {
host.pendingHooks = new Map([['s1', new Set(['permission_prompt'])]]);
try {
const row = host._sidebarRichRow('s1', { status: 'idle', lastActivityAt: 5, paneExit: { status: 0, at: 42 } });
expect(row.exited).toBe(false);
expect(row.pill).toBe('needs you');
} finally {
host.pendingHooks = undefined;
}
});
it('reads idle for a live session', () => {
const row = host._sidebarRichRow('s1', { status: 'idle', lastActivityAt: 5 });
expect(row.exited).toBe(false);
expect(row.pill).toBe('idle');
expect(row.since).toEqual({ key: 'idle', at: 5 });
});
it('styles the exited pill on both rich surfaces', () => {
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
expect(css).toContain('html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--exited');
expect(css).toContain('.tab-rail .tab-pill--exited');
});
});
describe('what colour the status dot ends up', () => { describe('what colour the status dot ends up', () => {
/* /*
* The dot renders from `status`, which stays `idle` or `busy` for an exited * The dot renders from `status`, which stays `idle` or `busy` for an exited
+2 -2
View File
@@ -84,7 +84,7 @@ describe('watching badge on a rich session row', () => {
// pill that says which one still decides the row; this badge only adds a fact. // pill that says which one still decides the row; this badge only adds a fact.
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {')); const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {')); const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {'));
expect(body).toContain('tab-pill tab-pill--${escapeHtml(row.state)}'); expect(body).toContain('tab-pill tab-pill--${escapeHtml(pillMod)}');
expect(body).toContain('tab-pill tab-pill--watching'); expect(body).toContain('tab-pill tab-pill--watching');
expect(body).toContain('Still running in the background:'); expect(body).toContain('Still running in the background:');
}); });
@@ -93,7 +93,7 @@ describe('watching badge on a rich session row', () => {
// The meta line is rebuilt only when this signature moves, so a badge left out of // The meta line is rebuilt only when this signature moves, so a badge left out of
// it would appear and disappear a render late, or not at all. // it would appear and disappear a render late, or not at all.
expect(app).toContain( expect(app).toContain(
'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}`"
); );
}); });
+5
View File
@@ -1003,6 +1003,11 @@ describe('parsePaneRows', () => {
expect(row.exitStatus).toBe(0); expect(row.exitStatus).toBe(0);
}); });
it('still reads the pid when a trailing field is junk', () => {
// Carried over from the retired parsePaneList case 'splits on the first separator only'.
expect(pids('codeman-aaaa|1234|extra-field').get('codeman-aaaa')).toBe(1234);
});
it('calls a non-numeric dead flag unknown rather than false', () => { it('calls a non-numeric dead flag unknown rather than false', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|?||'); const [row] = parsePaneRows('codeman-aaaa|1234|?||');
expect(row.dead).toBeUndefined(); expect(row.dead).toBeUndefined();