mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
The phone overview and the desktop tab rail list the same sessions, so
they now share one order (CodemanSessionOrder in constants.js, pure and
unit-tested): blocked on you first (longest-blocked at the top), then
running longest-turn-first, then quiet most-recently-quiet first.
The tiebreak flips direction halfway down on purpose: for a state a
session is still in, longer is more urgent; for a state it has stopped
in, more recent is more relevant. The running group keys off the pane's
last Enter (lastSubmitAt), never lastActivityAt, because a working pane
repaints about once a second and would rank every turn as freshly
started. A 0 stamp means "unknown" and sorts last within its state.
The desktop rail was previously in raw tab order. Its number badge stays
the Alt+1..9 index, so on a sorted rail it deliberately no longer runs
1,2,3 downward: it names a shortcut, not a row position. Its second
stamp changes from "active 3m ago" to the state duration the order is
computed from ("created 1d ago . working 40m"), since both working rows
otherwise read "active just now" and the order looked arbitrary.
The tab strip itself is untouched: still user-ordered and drag-sortable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
// Port: none (pure model + static markup assertions — no browser, no server).
|
|
//
|
|
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
|
|
// the welcome overlay's left gutter. Two things about it can silently go wrong
|
|
// and are pinned here: the row ORDER (shared with the phone overview via
|
|
// CodemanSessionOrder, with the number badge still carrying the TAB index so
|
|
// Alt+N keeps working), and the WIDTH GATE, which lives in two places at once —
|
|
// the JS constant and a CSS media query — because the column is absolutely
|
|
// positioned and would overlap the search panel in a narrow window.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
/** Minimal fake DOM node — enough surface for the programmatic row builders. */
|
|
function fakeElement(): any {
|
|
const el: any = {
|
|
className: '',
|
|
type: '',
|
|
title: '',
|
|
textContent: '',
|
|
dataset: {},
|
|
style: {},
|
|
children: [] as any[],
|
|
setAttribute() {},
|
|
appendChild(child: any) {
|
|
el.children.push(child);
|
|
return child;
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
|
|
* `shouldUseMobileOverview` from mobile-overview.js and the row comparator from
|
|
* constants.js, so all three files run in the same context, which is also the
|
|
* point: if that reuse ever breaks, these tests stop loading rather than
|
|
* quietly testing a divergent copy.
|
|
*/
|
|
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
console,
|
|
window: { innerWidth },
|
|
document: {
|
|
getElementById: () => null,
|
|
createElement: () => fakeElement(),
|
|
createElementNS: () => fakeElement(),
|
|
},
|
|
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
|
|
});
|
|
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
|
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
|
}
|
|
|
|
const app = new (CodemanApp as any)();
|
|
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
|
|
app._shortenHomePath = (p: string) => (p || '').replace(/^\/home\/[^/]+\//, '~/');
|
|
app.loadAppSettingsFromStorage = () => ({});
|
|
Object.assign(app, overrides);
|
|
return app;
|
|
}
|
|
|
|
const CASES = [{ name: 'claudeman', path: '/home/arkon/default/claudeman', location: 'local' }];
|
|
|
|
function sessionMap(list: Array<Record<string, any>>) {
|
|
return new Map(
|
|
list.map((over) => {
|
|
const s = { id: 'x', status: 'idle', mode: 'claude', workingDir: '/home/arkon/default/claudeman', ...over };
|
|
return [s.id, s];
|
|
})
|
|
);
|
|
}
|
|
|
|
describe('home sessions column: model', () => {
|
|
it('hoists a session blocked on you, and keeps its badge on the TAB index', () => {
|
|
// The badge names the Alt+N shortcut, so a sorted rail shows 2,1,3 rather
|
|
// than renumbering itself 1,2,3 and lying about which key selects what.
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
|
|
sessionOrder: ['first', 'needy', 'third'],
|
|
cases: CASES,
|
|
pendingHooks: new Map([['needy', new Set(['permission_prompt'])]]),
|
|
});
|
|
|
|
const rows = app.buildHomeSessionRows();
|
|
expect(rows.map((r: any) => r.id)).toEqual(['needy', 'first', 'third']);
|
|
expect(rows.map((r: any) => r.orderIndex)).toEqual([1, 0, 2]);
|
|
expect(rows[0].state).toBe('needs');
|
|
expect(rows[0].pill).toBe('needs you');
|
|
});
|
|
|
|
it('orders running sessions longest-turn-first and quiet ones most-recent-first', () => {
|
|
// The same rule the phone overview follows, and the reason the rail exists:
|
|
// what is running longest is what is most likely to be done or stuck, and
|
|
// once nothing is running the session that just stopped is the one you came
|
|
// back for.
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([
|
|
{ id: 'young-turn', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
|
|
{ id: 'old-turn', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
|
|
{ id: 'stale-idle', status: 'idle', lastActivityAt: 2_000 },
|
|
{ id: 'fresh-idle', status: 'idle', lastActivityAt: 8_000 },
|
|
]),
|
|
sessionOrder: ['young-turn', 'old-turn', 'stale-idle', 'fresh-idle'],
|
|
cases: CASES,
|
|
});
|
|
|
|
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual([
|
|
'old-turn',
|
|
'young-turn',
|
|
'fresh-idle',
|
|
'stale-idle',
|
|
]);
|
|
});
|
|
|
|
it('shows a session that is not in the order list yet', () => {
|
|
// A freshly created session exists in this.sessions before the order array
|
|
// catches up; its tab is already on screen, so its row must be too.
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'known' }, { id: 'fresh' }]),
|
|
sessionOrder: ['known'],
|
|
cases: CASES,
|
|
});
|
|
|
|
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual(['known', 'fresh']);
|
|
});
|
|
|
|
it('classifies state through the shared phone-overview helper', () => {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([
|
|
{ id: 'w', status: 'busy' },
|
|
{ id: 'i', status: 'idle' },
|
|
{ id: 'd', status: 'stopped' },
|
|
{ id: 'e', status: 'error' },
|
|
]),
|
|
sessionOrder: ['w', 'i', 'd', 'e'],
|
|
cases: CASES,
|
|
});
|
|
|
|
// Unstamped rows fall back to the tab order inside a state, so this reads
|
|
// as the state ranking alone: an errored session is blocked on you.
|
|
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
|
|
['error', 'error'],
|
|
['working', 'working'],
|
|
['idle', 'idle'],
|
|
['done', 'done'],
|
|
]);
|
|
});
|
|
|
|
it('labels a row with its case and a short backend badge', () => {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', name: 'w1-claudeman', mode: 'codex' }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
|
|
const [row] = app.buildHomeSessionRows();
|
|
expect(row.caseName).toBe('claudeman');
|
|
expect(row.modeBadge).toBe('cx');
|
|
// claude is the default backend and gets no badge — the strip does the same.
|
|
const plain = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', mode: 'claude' }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
expect(plain.buildHomeSessionRows()[0].modeBadge).toBe('');
|
|
});
|
|
|
|
it('badges every non-claude backend, so a new run mode cannot read as claude here', () => {
|
|
// The badge map is a per-mode lookup with a '' fallback, so a mode missing from it
|
|
// is indistinguishable from claude in this rail while the tab strip badges it fine.
|
|
for (const [mode, badge] of [
|
|
['shell', 'sh'],
|
|
['opencode', 'oc'],
|
|
['codex', 'cx'],
|
|
['gemini', 'gm'],
|
|
['antigravity', 'ag'],
|
|
['pi', 'pi'],
|
|
] as const) {
|
|
const app = loadHomeSessionsApp({
|
|
sessions: sessionMap([{ id: 'a', mode }]),
|
|
sessionOrder: ['a'],
|
|
cases: CASES,
|
|
});
|
|
expect(app.buildHomeSessionRows()[0].modeBadge).toBe(badge);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('home sessions column: gate', () => {
|
|
it('renders on a wide desktop', () => {
|
|
const app = loadHomeSessionsApp({}, 1512);
|
|
expect(app.shouldShowHomeSessions()).toBe(true);
|
|
});
|
|
|
|
it('stays out of a window too narrow to hold it beside the centered content', () => {
|
|
// Absolutely positioned: below the gate it would overlap the search panel
|
|
// rather than push it aside.
|
|
expect(loadHomeSessionsApp({}, 1100).shouldShowHomeSessions()).toBe(false);
|
|
expect(loadHomeSessionsApp({}, 1179).shouldShowHomeSessions()).toBe(false);
|
|
expect(loadHomeSessionsApp({}, 1180).shouldShowHomeSessions()).toBe(true);
|
|
});
|
|
|
|
it('yields to the phone overview, which already lists the same sessions', () => {
|
|
const app = loadHomeSessionsApp({}, 390);
|
|
expect(app.shouldUseMobileOverview()).toBe(true);
|
|
expect(app.shouldShowHomeSessions()).toBe(false);
|
|
});
|
|
|
|
it('stays out of a popped-out solo window', () => {
|
|
expect(loadHomeSessionsApp({ isSoloWindow: true }, 1512).shouldShowHomeSessions()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('home sessions column: wiring', () => {
|
|
const js = readFileSync(resolve(PUBLIC, 'home-sessions.js'), 'utf8');
|
|
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
|
|
|
it('keeps the JS width gate and the CSS media query in agreement', () => {
|
|
// Two gates for one decision: the JS one hides the element, the CSS one is
|
|
// the backstop for a resize that outruns the matchMedia listener. Drift
|
|
// means a column that overlaps the welcome content at some widths.
|
|
const jsMin = Number(/HOME_SESSIONS_MIN_WIDTH = (\d+)/.exec(js)?.[1]);
|
|
const cssMax = Number(/@media \(max-width: (\d+)px\) \{\s*\.home-sessions \{/.exec(css)?.[1]);
|
|
expect(jsMin).toBeGreaterThan(0);
|
|
expect(cssMax).toBe(jsMin - 1);
|
|
});
|
|
|
|
it('re-asserts [hidden] over the flex display', () => {
|
|
// .home-sessions is display:flex, which defeats the `hidden` attribute — the
|
|
// module's only visibility lever — unless this rule exists.
|
|
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
|
|
});
|
|
|
|
it('reuses the tab-load spinner rather than declaring a second one', () => {
|
|
// The working ring is the same motion a tab shows while it loads, on both
|
|
// home screens. Re-declaring the keyframes here is how they drift apart.
|
|
expect(js).toContain('tab-load-spin');
|
|
expect(css).toMatch(/\.home-sessions-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
|
expect(css).not.toMatch(/@keyframes home-sessions-load-spin/);
|
|
const mobileCss = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
|
|
expect(mobileCss).toMatch(/\.mobile-overview-dot--working::after[\s\S]*?animation: tab-load-spin/);
|
|
});
|
|
|
|
it('gives the working dot the same green halo on both home screens', () => {
|
|
const halo = /box-shadow: 0 0 8px 2px color-mix\(in srgb, var\(--green\) 55%, transparent\)/;
|
|
expect(css).toMatch(halo);
|
|
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(halo);
|
|
});
|
|
|
|
it('ships the container hidden, inside the welcome overlay, loaded after mobile-overview.js', () => {
|
|
expect(html).toMatch(/<aside class="home-sessions" id="homeSessions" hidden><\/aside>/);
|
|
const overlayStart = html.indexOf('id="welcomeOverlay"');
|
|
const aside = html.indexOf('id="homeSessions"');
|
|
const content = html.indexOf('class="welcome-content"');
|
|
expect(overlayStart).toBeGreaterThan(-1);
|
|
expect(aside).toBeGreaterThan(overlayStart);
|
|
expect(aside).toBeLessThan(content);
|
|
// Load order: the module reuses prototype methods installed by
|
|
// mobile-overview.js and the comparator installed by constants.js. Compare
|
|
// the <script> tags, not any mention: both files are named in explanatory
|
|
// comments earlier in the document.
|
|
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
|
|
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
|
|
});
|
|
});
|