diff --git a/src/web/public/app.js b/src/web/public/app.js index f3b502b9..72af7bba 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1116,12 +1116,17 @@ class CodemanApp { if (digitMatch) { const idx = parseInt(digitMatch[1], 10) - 1; // Sessions occupy 1..N and web tabs continue from N+1, matching the - // numbers actually painted on the tabs. - if (idx < this.sessionOrder.length) { + // numbers actually painted on the tabs. Resolve through the same + // live-session projection the render paints: sessionOrder can + // transiently hold a dead id (delete raced against the order sync), + // and raw indexing then names the wrong tab for every key to its + // right, web tabs included. + const live = this.sessionOrder.filter((id) => this.sessions.has(id)); + if (idx < live.length) { e.preventDefault(); - this.selectSession(this.sessionOrder[idx]); + this.selectSession(live[idx]); } else { - const webIdx = idx - this.sessionOrder.length; + const webIdx = idx - live.length; const webId = (this.webviewOrder || [])[webIdx]; if (webId) { e.preventDefault(); diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index eef976c3..a8d2f969 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -118,14 +118,16 @@ Object.assign(CodemanApp.prototype, { * A WORKING pane is the opposite: it repaints about once a second, so its * last-activity stamp is always "now" and would report every running turn as * 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is - * persisted server-side and therefore survives a Codeman restart. A session - * that has never submitted has no anchor at all, and gets no stamp rather than - * a made-up one. + * persisted server-side and therefore survives a Codeman restart. A working + * session with NO submit stamp falls back to `lastActivityAt`, because that is + * exactly what `sessionActivityAnchor` (constants.js) sorts it by: a row must + * never be ranked by a number it does not show. * * @returns {{key: string, at: number}|null} */ _mobileOverviewSince(state, session) { - const at = state === 'working' ? Number(session.lastSubmitAt) || 0 : Number(session.lastActivityAt) || 0; + const activeAt = Number(session.lastActivityAt) || 0; + const at = state === 'working' ? Number(session.lastSubmitAt) || activeAt : activeAt; if (!at) return null; return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at }; }, diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index d59d7c39..440b18c9 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -148,6 +148,11 @@ export function registerHookEventRoutes( ...safeData, ...(approvalId && { approvalId }), }); + // Full state ride-along, same shape as the working/idle handlers: the home + // screens rank the blocked group on lastActivityAt, and without this a + // permission prompt raised after page load kept ranking by whatever stamp + // the browser loaded with. Debounced, so a hook burst costs one broadcast. + ctx.broadcastSessionStateDebounced(sessionId); // Send push notifications for hook events ctx.sendPushNotifications(`hook:${event}`, { diff --git a/test/home-sessions.test.ts b/test/home-sessions.test.ts index fd5e19ad..8ef9d8c8 100644 --- a/test/home-sessions.test.ts +++ b/test/home-sessions.test.ts @@ -270,3 +270,75 @@ describe('home sessions column: wiring', () => { expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"')); }); }); + +describe('home screens: one order, one numbering', () => { + it('produces the same order on the rail and the phone overview for one input', () => { + // Both surfaces claim to share CodemanSessionOrder. Nothing used to assert + // they actually produce one order for one input, so a future local sort in + // either builder would silently split them. The rail is one list; the phone + // splits NEEDS YOU / CURRENT, so rail order must equal the concatenation. + const fixture = [ + { id: 'blocked-new', lastActivityAt: 5_000 }, + { id: 'idle-old', lastActivityAt: 3_000 }, + { id: 'run-new', status: 'busy', lastSubmitAt: 8_000, lastActivityAt: 9_500 }, + { id: 'blocked-old', lastActivityAt: 1_000 }, + { id: 'run-old', status: 'busy', lastSubmitAt: 2_000, lastActivityAt: 9_600 }, + { id: 'idle-new', lastActivityAt: 9_000 }, + ]; + const pendingHooks = new Map([ + ['blocked-new', new Set(['permission_prompt'])], + ['blocked-old', new Set(['permission_prompt'])], + ]); + const sessionOrder = fixture.map((s) => s.id); + const app = loadHomeSessionsApp({ + sessions: sessionMap(fixture), + sessionOrder, + cases: CASES, + pendingHooks, + }); + + const railIds = app.buildHomeSessionRows().map((r: any) => r.id); + const model = app.buildMobileOverviewModel({ + sessions: app.sessions, + cases: CASES, + sessionOrder, + pendingHooks, + }); + const phoneIds = [...model.needsYou, ...model.current].map((r: any) => r.id); + + expect(railIds).toEqual(phoneIds); + // And the shared order is the documented one: blocked longest-first, then + // running longest-first, then quiet newest-first. + expect(railIds).toEqual(['blocked-old', 'blocked-new', 'run-old', 'run-new', 'idle-new', 'idle-old']); + }); + + it('numbers rows over the LIVE projection when sessionOrder holds a dead id', () => { + // sessionOrder can transiently contain a deleted session (delete raced the + // order sync). The strip paints numbers over live sessions only, and the + // Alt+digit handler resolves through the same projection, so the rail must + // number alpha=1, beta=2 with no hole where the ghost sits. + const app = loadHomeSessionsApp({ + sessions: sessionMap([{ id: 'alpha' }, { id: 'beta' }]), + sessionOrder: ['ghost', 'alpha', 'beta'], + cases: CASES, + }); + expect(app.buildHomeSessionRows().map((r: any) => [r.id, r.orderIndex])).toEqual([ + ['alpha', 0], + ['beta', 1], + ]); + }); + + it('Alt+digit resolves through the live-session projection in app.js', () => { + // Static guard for the handler half of the invariant above: the digit + // branch must filter sessionOrder against live sessions before indexing, + // for sessions AND for the web-tab continuation. + const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + const start = appJs.indexOf('^Digit([1-9])$'); + expect(start).toBeGreaterThan(-1); + const branch = appJs.slice(start, start + 1200); + expect(branch).toContain('this.sessionOrder.filter((id) => this.sessions.has(id))'); + expect(branch).toContain('idx < live.length'); + expect(branch).toContain('idx - live.length'); + expect(branch).not.toContain('this.sessionOrder[idx]'); + }); +}); diff --git a/test/mobile-overview.test.ts b/test/mobile-overview.test.ts index 222674d0..3059a2d7 100644 --- a/test/mobile-overview.test.ts +++ b/test/mobile-overview.test.ts @@ -277,15 +277,28 @@ describe('mobile overview model', () => { expect(rows.i.createdAt).toBe(now - 7200_000); }); - it('leaves the stamp off rather than inventing an anchor', () => { + it('falls back to the sort anchor for a working row with no submit stamp', () => { + // A session that has never submitted has no turn start to measure from, but + // `sessionActivityAnchor` still RANKS it by lastActivityAt. The stamp must + // show that same number rather than nothing: a row sorted by a value it + // does not display reads as randomly placed. + const now = Date.now(); const app = loadOverviewApp(); const model = app.buildMobileOverviewModel({ - // A session that has never submitted has no turn start to measure from. - sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.now() })], + sessions: [session({ id: 'w', status: 'busy', lastActivityAt: now })], + cases: CASES, + }); + expect(model.current[0].since).toEqual({ key: 'working', at: now }); + expect(model.current[0].createdAt).toBe(0); + }); + + it('still leaves the stamp off when there is no anchor at all', () => { + const app = loadOverviewApp(); + const model = app.buildMobileOverviewModel({ + sessions: [session({ id: 'w', status: 'busy' })], cases: CASES, }); expect(model.current[0].since).toBeNull(); - expect(model.current[0].createdAt).toBe(0); }); it('formats a moment as "ago" and a span as a bare duration', () => {