mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
feat: add the TUI session model, classification and cursor
Rows are the ones GET /api/sessions/unified already returns and blocked states are the items the approvals inbox already parsed, both imported as types only so a CLI process pulls in neither the server nor node-pty. Classification speaks the web UI's language (red blocked, yellow waiting, green working) so a user with both surfaces open never has to translate between them. Groups order by how long a session has been in its state, which is why WORKING anchors on the pane's last Enter: a working pane repaints about once a second, so its last-activity stamp always says "now". Selection is tracked by session id, never by row index: rows re-sort under the cursor whenever a session starts working or an approval lands, and an index-tracked cursor would quietly move the selection to another session between two keystrokes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for TUI classification, grouping and the cursor.
|
||||
*
|
||||
* Rows are built in the shape `GET /api/sessions/unified` really returns
|
||||
* (`UnifiedSessionItem`, `sources` and all), and approvals in the shape the
|
||||
* approvals inbox really emits, so a change to either surface breaks these
|
||||
* tests rather than the dashboard.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
||||
import {
|
||||
buildRows,
|
||||
classifySession,
|
||||
createTuiModel,
|
||||
flattenRows,
|
||||
groupSessions,
|
||||
mergeSessionRow,
|
||||
} from '../../src/tui/tui-model.js';
|
||||
import type { TuiSessionRow } from '../../src/tui/tui-types.js';
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function session(overrides: Partial<TuiSessionRow> & { sessionId: string }): TuiSessionRow {
|
||||
return {
|
||||
sources: ['live'],
|
||||
name: overrides.sessionId,
|
||||
mode: 'claude',
|
||||
status: 'idle',
|
||||
workingDir: '/home/dev/case',
|
||||
createdAt: NOW - 60_000,
|
||||
lastActivityAt: NOW - 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approval(overrides: Partial<ApprovalItem> & { sessionId: string }): ApprovalItem {
|
||||
return {
|
||||
id: `${overrides.sessionId}:1`,
|
||||
sessionName: overrides.sessionId,
|
||||
kind: 'permission',
|
||||
createdAt: NOW - 30_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalMap(items: ApprovalItem[]): Map<string, ApprovalItem> {
|
||||
return new Map(items.map((item) => [item.sessionId, item]));
|
||||
}
|
||||
|
||||
describe('classifySession', () => {
|
||||
it('classifies live sessions by status', () => {
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'busy' }))).toBe('working');
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'idle', isWorking: true }))).toBe('working');
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'idle' }))).toBe('idle');
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'stopped' }))).toBe('idle');
|
||||
});
|
||||
|
||||
it('puts an errored session in the needs-you tier', () => {
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'error' }))).toBe('waiting');
|
||||
});
|
||||
|
||||
it('classifies by the pending prompt, which outranks a stale busy status', () => {
|
||||
const row = session({ sessionId: 'a', status: 'busy' });
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission' }))).toBe('blocked-permission');
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'question' }))).toBe('blocked-question');
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting');
|
||||
});
|
||||
|
||||
it('classifies a row the server no longer has live as history', () => {
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent');
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent');
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['history', 'live'] }))).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupSessions', () => {
|
||||
it('always returns the four groups in display order', () => {
|
||||
expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']);
|
||||
expect(groupSessions([]).map((group) => group.label)).toEqual(['NEEDS YOU', 'WORKING', 'IDLE', 'RECENT']);
|
||||
});
|
||||
|
||||
it('orders NEEDS YOU by how long each has been blocked, longest first', () => {
|
||||
const sessions = [session({ sessionId: 'fresh' }), session({ sessionId: 'old' }), session({ sessionId: 'middle' })];
|
||||
const approvals = approvalMap([
|
||||
approval({ sessionId: 'fresh', createdAt: NOW - 5_000 }),
|
||||
approval({ sessionId: 'old', createdAt: NOW - 900_000, kind: 'idle' }),
|
||||
approval({ sessionId: 'middle', createdAt: NOW - 60_000, kind: 'question' }),
|
||||
]);
|
||||
const groups = groupSessions(buildRows(sessions, approvals));
|
||||
expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['old', 'middle', 'fresh']);
|
||||
});
|
||||
|
||||
it('orders WORKING by turn start, longest-running first', () => {
|
||||
const sessions = [
|
||||
session({ sessionId: 'short', status: 'busy', lastSubmitAt: NOW - 10_000, lastActivityAt: NOW }),
|
||||
session({ sessionId: 'long', status: 'busy', lastSubmitAt: NOW - 3_600_000, lastActivityAt: NOW }),
|
||||
session({ sessionId: 'nosubmit', status: 'busy', createdAt: NOW - 500, lastActivityAt: NOW }),
|
||||
];
|
||||
const groups = groupSessions(buildRows(sessions));
|
||||
expect(groups[1].rows.map((row) => row.session.sessionId)).toEqual(['long', 'short', 'nosubmit']);
|
||||
});
|
||||
|
||||
it('orders IDLE and RECENT newest first', () => {
|
||||
const sessions = [
|
||||
session({ sessionId: 'i-old', lastActivityAt: NOW - 900_000 }),
|
||||
session({ sessionId: 'i-new', lastActivityAt: NOW - 1_000 }),
|
||||
session({ sessionId: 'h-old', sources: ['history'], lastActivityAt: NOW - 86_400_000 }),
|
||||
session({ sessionId: 'h-new', sources: ['history'], lastActivityAt: NOW - 3_600_000 }),
|
||||
];
|
||||
const groups = groupSessions(buildRows(sessions));
|
||||
expect(groups[2].rows.map((row) => row.session.sessionId)).toEqual(['i-new', 'i-old']);
|
||||
expect(groups[3].rows.map((row) => row.session.sessionId)).toEqual(['h-new', 'h-old']);
|
||||
});
|
||||
|
||||
it('caps RECENT', () => {
|
||||
const sessions = Array.from({ length: 20 }, (_, i) =>
|
||||
session({ sessionId: `h${i}`, sources: ['history'], lastActivityAt: NOW - i * 1000 })
|
||||
);
|
||||
expect(groupSessions(buildRows(sessions))[3].rows).toHaveLength(8);
|
||||
expect(groupSessions(buildRows(sessions), { recentLimit: 3 })[3].rows.map((r) => r.session.sessionId)).toEqual([
|
||||
'h0',
|
||||
'h1',
|
||||
'h2',
|
||||
]);
|
||||
expect(groupSessions(buildRows(sessions), { recentLimit: 0 })[3].rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts deterministically when the anchors tie', () => {
|
||||
const sessions = [
|
||||
session({ sessionId: 'b', lastActivityAt: NOW }),
|
||||
session({ sessionId: 'a', lastActivityAt: NOW }),
|
||||
];
|
||||
expect(groupSessions(buildRows(sessions))[2].rows.map((row) => row.session.sessionId)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('sorts an unknown anchor last in both directions', () => {
|
||||
const withAnchor = session({ sessionId: 'known', lastActivityAt: NOW - 1000 });
|
||||
const without = session({ sessionId: 'unknown', lastActivityAt: undefined, createdAt: undefined });
|
||||
const idle = groupSessions(buildRows([without, withAnchor]))[2];
|
||||
expect(idle.rows.map((row) => row.session.sessionId)).toEqual(['known', 'unknown']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSessionRow', () => {
|
||||
it('keeps fields the incoming row does not carry', () => {
|
||||
const existing = session({ sessionId: 'a', firstPrompt: 'hello', inputTokens: 10 });
|
||||
const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['live'], status: 'busy' });
|
||||
expect(merged.firstPrompt).toBe('hello');
|
||||
expect(merged.inputTokens).toBe(10);
|
||||
expect(merged.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('lets a session lose its live source when the server drops it', () => {
|
||||
const existing = session({ sessionId: 'a', sources: ['live', 'persisted'] });
|
||||
const merged = mergeSessionRow(existing, { sessionId: 'a', sources: ['history'] });
|
||||
expect(merged.sources).toEqual(['history']);
|
||||
expect(classifySession(merged)).toBe('recent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the store', () => {
|
||||
it('selects the first row as soon as there is one', () => {
|
||||
const model = createTuiModel();
|
||||
expect(model.selectedSession()).toBeNull();
|
||||
model.replaceSessions([session({ sessionId: 'a' }), session({ sessionId: 'b' })]);
|
||||
expect(model.selectedId).toBe(model.rows()[0].session.sessionId);
|
||||
});
|
||||
|
||||
it('moves the cursor over rows only, wrapping at both ends', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([
|
||||
session({ sessionId: 'needs' }),
|
||||
session({ sessionId: 'work', status: 'busy', lastSubmitAt: NOW - 1000 }),
|
||||
session({ sessionId: 'idle' }),
|
||||
session({ sessionId: 'past', sources: ['history'] }),
|
||||
]);
|
||||
model.setApprovals([approval({ sessionId: 'needs' })]);
|
||||
// One row per group: the cursor must cross the group headers without stopping.
|
||||
expect(model.rows().map((row) => row.session.sessionId)).toEqual(['needs', 'work', 'idle', 'past']);
|
||||
|
||||
model.select('needs');
|
||||
model.moveCursor(1);
|
||||
expect(model.selectedId).toBe('work');
|
||||
model.moveCursor(-1);
|
||||
expect(model.selectedId).toBe('needs');
|
||||
model.moveCursor(-1);
|
||||
expect(model.selectedId).toBe('past');
|
||||
model.moveCursor(1);
|
||||
expect(model.selectedId).toBe('needs');
|
||||
});
|
||||
|
||||
it('jumps by 1-based index and refuses one that is off the list', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([
|
||||
session({ sessionId: 'a', lastActivityAt: NOW }),
|
||||
session({ sessionId: 'b', lastActivityAt: NOW - 1 }),
|
||||
session({ sessionId: 'c', lastActivityAt: NOW - 2 }),
|
||||
]);
|
||||
expect(model.cursorToIndex(3)).toBe(true);
|
||||
expect(model.selectedId).toBe('c');
|
||||
expect(model.cursorToIndex(9)).toBe(false);
|
||||
expect(model.selectedId).toBe('c');
|
||||
expect(model.cursorToIndex(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the selection on its session when the rows re-sort under it', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([
|
||||
session({ sessionId: 'a', lastActivityAt: NOW }),
|
||||
session({ sessionId: 'b', lastActivityAt: NOW - 1000 }),
|
||||
]);
|
||||
model.select('b');
|
||||
expect(model.rows()[1].session.sessionId).toBe('b');
|
||||
|
||||
// b becomes blocked and jumps to the top of the list.
|
||||
model.setApprovals([approval({ sessionId: 'b' })]);
|
||||
expect(model.rows()[0].session.sessionId).toBe('b');
|
||||
expect(model.selectedId).toBe('b');
|
||||
expect(model.selectedSession()?.state).toBe('blocked-permission');
|
||||
});
|
||||
|
||||
it('hands the cursor to whatever takes the place of a removed session', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([
|
||||
session({ sessionId: 'a', lastActivityAt: NOW }),
|
||||
session({ sessionId: 'b', lastActivityAt: NOW - 1 }),
|
||||
session({ sessionId: 'c', lastActivityAt: NOW - 2 }),
|
||||
]);
|
||||
model.select('b');
|
||||
model.removeSession('b');
|
||||
expect(model.selectedId).toBe('c');
|
||||
model.replaceSessions([session({ sessionId: 'a', lastActivityAt: NOW })]);
|
||||
expect(model.selectedId).toBe('a');
|
||||
model.replaceSessions([]);
|
||||
expect(model.selectedId).toBeNull();
|
||||
expect(model.selectedSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('merges an SSE update into the row it already has', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([session({ sessionId: 'a', firstPrompt: 'first thing' })]);
|
||||
model.upsertSession({ sessionId: 'a', sources: ['live'], status: 'busy', inputTokens: 5 });
|
||||
const row = model.rows()[0];
|
||||
expect(row.state).toBe('working');
|
||||
expect(row.session.firstPrompt).toBe('first thing');
|
||||
expect(row.session.inputTokens).toBe(5);
|
||||
});
|
||||
|
||||
it('counts only live sessions', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([
|
||||
session({ sessionId: 'a' }),
|
||||
session({ sessionId: 'b' }),
|
||||
session({ sessionId: 'h', sources: ['history'] }),
|
||||
]);
|
||||
expect(model.sessionCount).toBe(2);
|
||||
expect(flattenRows(model.groups())).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('tracks the confirm-kill overlay and only accepts the exact name', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([session({ sessionId: 'a', name: 'w4-api' })]);
|
||||
model.beginConfirmKill(model.rows()[0]);
|
||||
expect(model.mode).toBe('confirm-kill');
|
||||
expect(model.confirmSatisfied()).toBe(false);
|
||||
model.setConfirmInput('w4-ap');
|
||||
expect(model.confirmSatisfied()).toBe(false);
|
||||
model.setConfirmInput('w4-api');
|
||||
expect(model.confirmSatisfied()).toBe(true);
|
||||
model.closeOverlay();
|
||||
expect(model.mode).toBe('list');
|
||||
expect(model.confirm).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a session approval along with the session', () => {
|
||||
const model = createTuiModel();
|
||||
model.replaceSessions([session({ sessionId: 'a' })]);
|
||||
model.setApprovals([approval({ sessionId: 'a' })]);
|
||||
expect(model.approvalFor('a')).toBeDefined();
|
||||
model.removeSession('a');
|
||||
expect(model.approvalFor('a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user