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:
@@ -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 source = [
|
||||
appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0],
|
||||
appJs.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)?.[0],
|
||||
appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0],
|
||||
].join('\n');
|
||||
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 tab = dom.window.document.createElement('div');
|
||||
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;
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const tab = makeTab();
|
||||
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', () => {
|
||||
/*
|
||||
* 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.
|
||||
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
|
||||
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('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
|
||||
// it would appear and disappear a render late, or not at all.
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|?||');
|
||||
expect(row.dead).toBeUndefined();
|
||||
|
||||
Reference in New Issue
Block a user