mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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. |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
+33
-7
@@ -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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user