From f7e29758831f24615e86460c25e3ae6792aa831d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 17 Aug 2026 21:42:25 +0200 Subject: [PATCH] chore: version packages Gate the idle-alert acknowledgement to human selections: the boot restore, a solo window opening its target, and the post-close fallback no longer spend a yellow tab alert. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++ CLAUDE.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/web/public/app.js | 35 +++++-- test/session-select-ack-gate.test.ts | 132 +++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 test/session-select-ack-gate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 270a14ac..7c3a7d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # aicodeman +## 1.19.4 + +### Patch Changes + +- Only a human opening a session clears its yellow "waiting for input" tab alert. + + 1.19.2 made that clear durable and cross-device, which also meant the app itself could spend it: restoring your last session on page load, a popped-out window opening its target, and the fallback to another tab after you close the active one all counted as "I checked it", so a yellow tab could clear itself before you ever saw it. Those three app-driven selections are now marked and skip the acknowledgement, so the alert survives until you actually open the session. + + Everything a human does still clears it, on every surface: tapping a tab, tapping a row on the phone home screen, the keyboard tab shortcuts, and submitting a prompt into the session. The flag defaults to user-initiated, so a selection path nobody marked keeps acknowledging rather than leaving an alert nothing can clear. + ## 1.19.3 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 9274fe97..d5921f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 1.19.3 (must match `package.json`) +**Version**: 1.19.4 (must match `package.json`) ## Project Overview @@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. -**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. +**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. **Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`. diff --git a/package-lock.json b/package-lock.json index 94cd23d8..abc9895f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.19.3", + "version": "1.19.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.19.3", + "version": "1.19.4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index dcd6b51a..3f628788 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.19.3", + "version": "1.19.4", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 2b3ec621..a9be5b6f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1443,9 +1443,10 @@ class CodemanApp { document.body.classList.add('solo-mode'); const session = this.sessions.get(this.soloSessionId); if (!session) { this._showSoloSessionGone(); return; } - // Force re-select (handleInit cleared terminal state above). + // Force re-select (handleInit cleared terminal state above). `auto`: the + // window is opening its own target, which is not a human checking on it. this.activeSessionId = null; - this.selectSession(this.soloSessionId); + this.selectSession(this.soloSessionId, { auto: true }); const name = this.getSessionName(session) || 'Session'; const titleEl = document.getElementById('soloSessionTitle'); if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; } @@ -3651,10 +3652,13 @@ class CodemanApp { if (!restoreId || !this.sessions.has(restoreId)) { try { restoreId = localStorage.getItem('codeman-active-session'); } catch {} } + // `auto`: the app is restoring a session on load, not a human opening + // one, so a pending idle alert on that tab stays armed until it is + // actually tapped (see the userInitiated note in selectSession). if (restoreId && this.sessions.has(restoreId)) { - this.selectSession(restoreId); + this.selectSession(restoreId, { auto: true }); } else { - this.selectSession(this.sessionOrder[0]); + this.selectSession(this.sessionOrder[0], { auto: true }); } } } @@ -5171,13 +5175,21 @@ class CodemanApp { if (this._raiseDetached(sessionId)) return; } const forceReload = options?.forceReload === true; + // ⚠️ `auto: true` marks a selection the APP made rather than the human: + // the boot restore, a solo window opening its target, the fallback after + // the active session is deleted. Those must NOT spend a pending idle alert + // (the yellow survives until a real tap), because "the app put this on + // screen" is not "I checked it". The DEFAULT is user-initiated, so a call + // site nobody tagged fails toward acknowledging rather than toward an + // alert that can never be cleared. + const userInitiated = options?.auto !== true; if (this.activeSessionId === sessionId && !forceReload) { - // Clicking the tab you are already on is still "I checked it". The alert + // Tapping the tab you are already on is still "I checked it". The alert // can be armed on the ACTIVE tab (a live idle_prompt fires regardless of // which tab is showing, and so does the reload seed), and every other // clear path runs on the switch this early return skips, leaving a - // yellow tab that no click could clear. - this.markIdleAlertSeen(sessionId); + // yellow tab that no tap could clear. + if (userInitiated) this.markIdleAlertSeen(sessionId); return; } if (this.activeSessionId === sessionId && forceReload) { @@ -5237,8 +5249,9 @@ class CodemanApp { this.playTerminalEntrance?.(sessionId); // Clear idle hooks on view, but keep action hooks until user interacts. // Also acknowledged server-side, so the yellow does not come back on the - // next reload and the user's other devices clear it too. - this.markIdleAlertSeen(sessionId); + // next reload and the user's other devices clear it too. Skipped for an + // `auto` selection (see userInitiated above). + if (userInitiated) this.markIdleAlertSeen(sessionId); // Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status this._updateActiveTabImmediate(sessionId); // Handheld: the session drawer overlays the terminal, so slide it away now @@ -5717,8 +5730,10 @@ class CodemanApp { try { localStorage.removeItem('codeman-active-session'); } catch {} // Select another session or show welcome (use sessionOrder for consistent ordering) if (this.sessionOrder.length > 0 && this.sessions.size > 0) { + // `auto`: this tab was chosen by the app because the previous one + // went away, so it must not spend that session's idle alert. const nextSessionId = this.sessionOrder[0]; - this.selectSession(nextSessionId); + this.selectSession(nextSessionId, { auto: true }); } else { this.terminal.clear(); this.showWelcome(); diff --git a/test/session-select-ack-gate.test.ts b/test/session-select-ack-gate.test.ts new file mode 100644 index 00000000..8a93b167 --- /dev/null +++ b/test/session-select-ack-gate.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview A pending IDLE tab alert is spent by a HUMAN opening a session, + * never by the app putting one on screen. + * + * `selectSession()` acknowledges the session's idle approval item server-side + * (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is + * what makes "I checked it" survive a reload and reach the user's other + * devices. Three call sites are the APP choosing a session rather than the + * user: the boot restore, a solo (popped-out) window opening its target, and + * the fallback after the active session is deleted. Those pass `auto: true` + * and must not spend the alert, or a yellow tab would clear itself every time + * the page loaded and the user would never see it. + * + * The gate defaults to user-initiated on purpose: an untagged call site fails + * toward acknowledging (today's behavior) rather than toward an alert nothing + * can clear. This suite pins both halves, the runtime gate through the real + * `selectSession`, and the three tagged call sites as a source guard. + * + * Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts. + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const APP_PATH = resolve(import.meta.dirname, '../src/web/public/app.js'); +const APP_SOURCE = readFileSync(APP_PATH, 'utf8'); + +function loadCodemanAppClass() { + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const context = vm.createContext({ + console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: { isTouchDevice: () => false }, + }); + vm.runInContext(`${constants}\n${APP_SOURCE}\nglobalThis.__CodemanApp = CodemanApp;`, context); + return (context as { __CodemanApp: new () => unknown }).__CodemanApp; +} + +const CodemanApp = loadCodemanAppClass(); +const SID = 'session-with-a-yellow-tab'; + +/** + * Minimal instance: enough surface for selectSession to reach the + * acknowledgement line. Everything after it is DOM work that throws in this + * context, which is why callers swallow the rejection. + */ +function makeApp(activeSessionId: string | null) { + const app = Object.create((CodemanApp as { prototype: object }).prototype) as Record; + app.markIdleAlertSeen = vi.fn(); + app.pendingHooks = new Map([[SID, new Set(['idle_prompt'])]]); + app.activeSessionId = activeSessionId; + app.detachedSessions = new Set(); + app.isSoloWindow = false; + app._selectGeneration = 0; + app._shouldFocusTerminalForTabSwitch = () => false; + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._cleanupPreviousSession = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._updateSseSubscription = vi.fn(); + app.hideWelcome = vi.fn(); + app.sessions = new Map([[SID, { id: SID, name: 'w1' }]]); + return app as Record & { + selectSession: (id: string, opts?: Record) => Promise; + markIdleAlertSeen: ReturnType; + }; +} + +describe('selectSession acknowledgement gate', () => { + describe('switching to a session (the main path)', () => { + it('a user-initiated selection spends the idle alert', async () => { + const app = makeApp(null); + await app.selectSession(SID).catch(() => {}); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID); + }); + + it('an `auto` selection leaves it armed', async () => { + const app = makeApp(null); + await app.selectSession(SID, { auto: true }).catch(() => {}); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + }); + + describe('re-selecting the session already on screen (the early return)', () => { + it('a tap on the active tab spends the idle alert', async () => { + const app = makeApp(SID); + await app.selectSession(SID); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID); + }); + + it('an `auto` re-select leaves it armed', async () => { + const app = makeApp(SID); + await app.selectSession(SID, { auto: true }); + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + }); + + describe('the call sites the app drives itself', () => { + // Source guard: these three are the reason the flag exists. If a refactor + // moves or reformats them, fail loudly rather than silently going back to + // "every page load clears the user's yellow tab". + it.each([ + ['boot restore, stored session', 'this.selectSession(restoreId, { auto: true });'], + ['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'], + ['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'], + ['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'], + ])('%s passes auto: true', (_label, call) => { + expect(APP_SOURCE).toContain(call); + }); + + it('keyboard tab switching stays user-initiated', () => { + // Alt+1..9 and Alt+[/] are a human asking for that tab, so they keep + // acknowledging; only app-chosen selections are tagged. + expect(APP_SOURCE).toContain('this.selectSession(live[idx]);'); + expect(APP_SOURCE).toContain('this.selectSession(this.sessionOrder[nextIndex]);'); + }); + }); +});