diff --git a/.ai/contexts/README.md b/.ai/contexts/README.md index d6b91f74..cc74e52f 100644 --- a/.ai/contexts/README.md +++ b/.ai/contexts/README.md @@ -15,6 +15,7 @@ without re-reading `main.js`, now ~2600 LOC. | New IPC, preload bridge changes, renderer ↔ main protocol | [ipc-bridge](ipc-bridge.md) | | File-trigger watcher, harness input injection, idle-wait | [trigger-watcher](trigger-watcher.md) | | Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) | +| Busy/attention/response-ready state, the session-state domain module, the icon-slot projection | [session-state](session-state.md) | ## Reading order for a new contributor (~30 min) diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md new file mode 100644 index 00000000..78ac61e1 --- /dev/null +++ b/.ai/contexts/session-state.md @@ -0,0 +1,125 @@ +# Session state — one domain module, one icon slot + +Origin: issue #246 (step 3 of the alignment sequence #244 → #245 → #246 → #247). +Full design: the issue body and its 2026-09-11 lifecycle comment. This doc covers +what actually shipped, not the whole plan. + +## Migration status + +- **Steps 1-2: done.** `public/session-activity.js` split into a state part + (itself) and a DOM part (`public/session-activity-dom.js`); `public/session-state.js` + introduced and wired behind `applyActivityClasses` for **local-pty only**. +- **Steps 3-5: pending.** `remote-activity-ui.js`/`remote-activity.js` still write + `sessionBusyState` directly instead of going through a `remote-ssh` adapter; there + is no `local-transcript` adapter; subagent attribution is not routed through + `session-state.js` (`agentsBusy` exists in the model but nothing local-pty feeds + it yet — sidebar.js's `has-busy-agents` is still computed by + `parentHasActiveSubagent()`, independent of the domain module). + +## Shape + +Three files, one direction of dependency for data, the reverse for rendering: + +``` +public/session-state.js pure domain — no DOM, no IPC, no electron +public/session-activity-dom.js DOM projection — the only file allowed to + write .cli-busy/.needs-attention/ + .response-ready/.has-busy-agents +public/session-activity.js Maps/Sets + setActivity/purgeActivityFor/ + rekeyActivityState/reconcileBusyState — + calls into session-activity-dom.js to render +``` + +`createSessionState(kind)` returns `{ apply(event), snapshot() }`. `kind` is +`'local-pty' | 'local-transcript' | 'remote-ssh'` (only `'local-pty'` is fed +today). Snapshot fields: + +| field | meaning | +|---|---| +| `kind` | which adapter produced this state | +| `liveness` | `'alive' \| 'dead' \| 'unknown'` — is the CLI process running | +| `attached` | Switchboard holds a PTY / ssh attach for it — **separate from liveness** (2026-09-11 lifecycle decision: a row is active because the process is alive, not because a tab is open) | +| `busy` | OSC 0 — generating | +| `waitingForInput` | idle, sitting at the prompt | +| `attention` | OSC 9 — needs the user right now (permission/approval/plan) | +| `responseReady` | subset of `waitingForInput`: idle **and** unseen when it went idle (the legacy "Claude finished, you haven't looked" rung). Not in the issue's original field list — added because the priority order names it as its own rung, distinct from plain `waitingForInput`; see "Design notes" below. | +| `agentsBusy` | subagents running under this session | +| `lastActivityAt` / `lastActivitySource` | last touch, for `local-transcript`/`remote-ssh` (unused by local-pty today) | +| `label` / `labelConfidence` | reserved, unused | +| `attachable` | reserved, unused | +| `archived` / `stale` | reserved, unused | + +Invariant enforced by `apply()`: `busy` / `waitingForInput` / `attention` are +mutually exclusive — going busy or attention clears the other two (and +`responseReady`, which only means something under `waitingForInput`). + +`renderSessionIcon(snapshot)` resolves the priority order — attention > +responseReady > busy > agentsBusy > waitingForInput > idle+age > stale > +archived — defensively (it does not trust the caller kept exclusivity) and +returns `{ classes, glyph, title }` for **one icon slot**. Only the four +rungs that map to an existing CSS class (`needs-attention`, `response-ready`, +`cli-busy`, `has-busy-agents`) carry a class today; the rest carry a glyph/title +only — the sidebar HTML/CSS shape (replacing the dot/pip with the icon slot) +is a later step, not part of this migration. + +## Design notes (deviations from the issue's literal text) + +- **`responseReady` added to the snapshot.** The issue's field list didn't + include it, but the priority order names "response-ready" as a rung distinct + from `waitingForInput` — impossible to reproduce with one boolean. Modeled + as `waitingForInput`'s narrower subset (idle + not yet seen when it went + idle), set via `apply({ type: 'busy', active: false, armReady })` — direct + translation of the pre-existing `setActivity(id, active, via, { armReady })` + contract in `session-activity.js`. +- **"Seen" (today's `activeSessionId` focus check) stays a caller decision, + not a domain fact.** `attached` (introduced 2026-09-11) means "Switchboard + holds a PTY for it", true for every open tab, not just the focused one — it + cannot stand in for "the user is looking at this row right now". The + `armReady` flag on the `busy` event carries that judgment in from the + adapter, same as before the split. +- Ports (`transcriptTouched`, `descriptorStatus`, `subagentSpawned/Completed`, + `attachable`, `label`, `archived`, `stale`) are implemented in `apply()` but + **not fed by any adapter yet** — they exist so steps 3-5 don't need another + domain-shape change. + +## Enforcement + +- `eslint.config.js`: `no-restricted-syntax` selectors, in every `public/**/*.js` + file except `session-activity-dom.js` (tests are a separate glob, exempt by + construction), forbid the four class names (`cli-busy`, `needs-attention`, + `response-ready`, `has-busy-agents`) in: `classList.add/remove/toggle/replace` + (string or template literal), `className` / `innerHTML` / `outerHTML` + assignments, `setAttribute(...)` and `insertAdjacentHTML(...)`; any computed + `classList[method](...)` call is refused outright because it hides the name. + Verified 2026-09-11 with a probe file: six bypass shapes red, an unrelated + class name green, 0 errors on the real renderer. Not caught, by nature: a + class name held in a variable or built by concatenation — a review item, not + a lint item. All prior direct writers (`app.js`, `sidebar.js`, + `session-activity.js` itself) were moved onto the DOM file's + `setNeedsAttention`/`setResponseReady`/`setCliBusy`/`setHasBusyAgents` + helpers so the rules start at zero violations. +- `session-activity-dom.js` resolves a busy + response-ready tie as busy + (`main` resolved it as response-ready). The tie is unreachable: `setActivity` + and `rekeyActivityState` keep the two sets exclusive before projection. Noted + so a future invariant break is read as such, not as a projection bug. +- `test/session-state-boundary.test.js`: source-grep (no `require()`, same + shape as `test/main-ctx-db-wiring.test.js`) asserting `session-state.js` + never references `document`, `window`, `require('electron')` or `ipcRenderer`. +- `test/session-state.test.js`: apply-sequence, exclusivity, priority order + (mutated once during development — reordering `PRIORITY` to put `agentsBusy` + first turned the three top-rung priority tests red; reverted), and + `renderSessionIcon` per rung. + +## Ports table (target shape, not all wired yet) + +| event | local-pty | local-transcript | remote-ssh | +|---|---|---|---| +| `busy` / `attention` (OSC 0 / 9) | yes | never | only while attached | +| `transcriptTouched(at)` | yes | yes (only signal) | yes (watch channel) | +| `descriptorStatus(status, at)` | yes | no (no live CLI) | yes (`main.js:539`) | +| `subagentSpawned` / `subagentCompleted` | yes | no | no today | + +An adapter without a PTY must never claim `waitingForInput` or `responseReady` +— it has no way to tell "thinking" from "done, unseen". It should only feed +`busy: unknown` (not modeled as a tri-state yet — reserved for step 3/4) plus +`lastActivityAt`. diff --git a/.ai/shared-guidelines.md b/.ai/shared-guidelines.md index 2d37e8dd..dcc19e62 100644 --- a/.ai/shared-guidelines.md +++ b/.ai/shared-guidelines.md @@ -13,6 +13,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A | Change SQLite, indexing, watcher, FTS, heatmap | [contexts/session-cache.md](contexts/session-cache.md) | | Change schedule cron / `.md` files / schedule spawn | [contexts/schedule-runner.md](contexts/schedule-runner.md) | | Change subagent grouping, transcript view, parent→child | [contexts/subagent-observability.md](contexts/subagent-observability.md) | +| Change busy/attention/response-ready state or the session-state domain module | [contexts/session-state.md](contexts/session-state.md) | | Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) | | Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) | | Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` | diff --git a/eslint.config.js b/eslint.config.js index 17f28f2c..9e166f53 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ // Main-process files (CommonJS) get a separate block with node globals. const globals = require('globals'); +const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .cli-busy/.needs-attention/.response-ready/.has-busy-agents — see .ai/contexts/session-state.md'; // Cross-file renderer globals: vars defined in one file and consumed by // another. The list mirrors the dependency comment at the top of @@ -149,6 +150,16 @@ const rendererCrossFileGlobals = { forgetActivitySeq: 'readonly', purgeActivityFor: 'readonly', pruneRemoteActivityTimers: 'readonly', + // public/session-state.js (pure domain, see .ai/contexts/session-state.md) + createSessionState: 'readonly', + renderSessionIcon: 'readonly', + // public/session-activity-dom.js — the only file allowed to write + // .cli-busy/.needs-attention/.response-ready/.has-busy-agents. + applyActivityClassesToElement: 'readonly', + setNeedsAttention: 'readonly', + setResponseReady: 'readonly', + setCliBusy: 'readonly', + setHasBusyAgents: 'readonly', // Third-party renderer libs loaded as - + + + diff --git a/public/session-activity-dom.js b/public/session-activity-dom.js new file mode 100644 index 00000000..fcfb5a08 --- /dev/null +++ b/public/session-activity-dom.js @@ -0,0 +1,46 @@ +// DOM projection for session activity, sole writer of the activity classes — see .ai/contexts/session-state.md + +function sessionItemEl(sessionId) { + return document.querySelector(`.session-item[data-session-id="${sessionId}"]`); +} + +function setNeedsAttention(el, on) { + if (el) el.classList.toggle('needs-attention', !!on); +} + +function setResponseReady(el, on) { + if (el) el.classList.toggle('response-ready', !!on); +} + +function setCliBusy(el, on) { + if (el) el.classList.toggle('cli-busy', !!on); +} + +function setHasBusyAgents(el, on) { + if (el) el.classList.toggle('has-busy-agents', !!on); +} + +// local-pty only for now — see session-state.md "migration status". +function computeBusyReadyClasses(sessionId) { + const busy = sessionBusyState.get(sessionId) === true; + const ready = !busy && responseReadySessions.has(sessionId); + const state = createSessionState('local-pty'); + if (busy) state.apply({ type: 'busy', active: true }); + else if (ready) state.apply({ type: 'busy', active: false, armReady: true }); + return renderSessionIcon(state.snapshot()).classes; +} + +// The only writer of .cli-busy and .response-ready — they are mutually exclusive. +function applyActivityClassesToElement(item, sessionId) { + if (!item) return; + const classes = computeBusyReadyClasses(sessionId); + const ready = classes.includes('response-ready'); + const busy = classes.includes('cli-busy'); + setResponseReady(item, ready); + setCliBusy(item, busy); + if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, fn: 'applyActivityClasses' }); +} + +function applyActivityClasses(sessionId) { + applyActivityClassesToElement(sessionItemEl(sessionId), sessionId); +} diff --git a/public/session-activity.js b/public/session-activity.js index b84dc267..237b8024 100644 --- a/public/session-activity.js +++ b/public/session-activity.js @@ -1,5 +1,7 @@ // Session activity state — busy / response-ready / attention. -// See .ai/contexts/ipc-bridge.md "Busy-state reconciliation". +// See .ai/contexts/ipc-bridge.md "Busy-state reconciliation" and +// .ai/contexts/session-state.md (DOM projection split out to +// session-activity-dom.js). const attentionSessions = new Set(); // sessions needing user action (OSC 9) const responseReadySessions = new Set(); // Claude finished, user hasn't looked (terminal state) @@ -20,20 +22,6 @@ function forgetActivitySeq(sessionId) { activitySeqBySession.delete(sessionId); } -function sessionItemEl(sessionId) { - return document.querySelector(`.session-item[data-session-id="${sessionId}"]`); -} - -// The only writer of .cli-busy and .response-ready — they are mutually exclusive. -function applyActivityClasses(sessionId) { - const item = sessionItemEl(sessionId); - if (!item) return; - const ready = responseReadySessions.has(sessionId); - item.classList.toggle('response-ready', ready); - item.classList.toggle('cli-busy', !ready && sessionBusyState.get(sessionId) === true); - if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': item.classList.contains('cli-busy'), fn: 'applyActivityClasses' }); -} - // Purge outside the active/idle transition (e.g. PTY gone); the only writer of the three collections besides setActivity/rekeyActivityState. function purgeActivityFor(sessionId, via) { if (window.ATRACE) window.atrace('store.purge', sessionId, { reason: via, busy: sessionBusyState.get(sessionId) ?? null, ready: responseReadySessions.has(sessionId), attention: attentionSessions.has(sessionId), fn: 'purgeActivityFor' }); @@ -41,8 +29,7 @@ function purgeActivityFor(sessionId, via) { responseReadySessions.delete(sessionId); sessionBusyState.delete(sessionId); forgetActivitySeq(sessionId); - const item = sessionItemEl(sessionId); - if (item) item.classList.remove('needs-attention'); + setNeedsAttention(sessionItemEl(sessionId), false); applyActivityClasses(sessionId); } @@ -83,7 +70,9 @@ function rekeyActivityState(oldId, newId) { if (oldId === newId) return; if (window.ATRACE) window.atrace('store.rekey', newId, { from: oldId, busy: sessionBusyState.get(oldId) ?? null, ready: responseReadySessions.has(oldId), attention: attentionSessions.has(oldId), fn: 'rekeyActivityState' }); const oldItem = sessionItemEl(oldId); - if (oldItem) oldItem.classList.remove('cli-busy', 'response-ready', 'needs-attention'); + setCliBusy(oldItem, false); + setResponseReady(oldItem, false); + setNeedsAttention(oldItem, false); if (sessionBusyState.has(oldId)) { sessionBusyState.set(newId, sessionBusyState.get(oldId)); @@ -92,8 +81,7 @@ function rekeyActivityState(oldId, newId) { if (responseReadySessions.delete(oldId)) responseReadySessions.add(newId); if (attentionSessions.delete(oldId)) { attentionSessions.add(newId); - const newItem = sessionItemEl(newId); - if (newItem) newItem.classList.add('needs-attention'); + setNeedsAttention(sessionItemEl(newId), true); } const seq = activitySeqBySession.get(oldId); if (seq !== undefined) { diff --git a/public/session-state.js b/public/session-state.js new file mode 100644 index 00000000..bcce1ee8 --- /dev/null +++ b/public/session-state.js @@ -0,0 +1,160 @@ +// Pure session-state domain model — see .ai/contexts/session-state.md +'use strict'; + +const PRIORITY = ['attention', 'responseReady', 'busy', 'agentsBusy', 'waitingForInput', 'idle', 'stale', 'archived']; + +const ICON_BY_RUNG = { + attention: { classes: ['needs-attention'], glyph: '!', title: 'Needs your attention' }, + responseReady: { classes: ['response-ready'], glyph: '●', title: 'Response ready' }, + busy: { classes: ['cli-busy'], glyph: '⠋', title: 'Working' }, + agentsBusy: { classes: ['has-busy-agents'], glyph: '◆', title: 'Subagents running' }, + waitingForInput: { classes: [], glyph: '○', title: 'Waiting for input' }, + idle: { classes: [], glyph: '', title: 'Idle' }, + stale: { classes: [], glyph: '', title: 'Stale' }, + archived: { classes: [], glyph: '', title: 'Archived' }, +}; + +function createSessionState(kind) { + let liveness = 'unknown'; // 'alive' | 'dead' | 'unknown' + let attached = false; // Switchboard holds a PTY / ssh attach for this session + let busy = false; + let waitingForInput = false; + let attention = false; + let responseReady = false; + let agentsBusy = false; + let lastActivityAt = null; + let lastActivitySource = null; + let label = null; + let labelConfidence = null; + let attachable = null; + let archived = false; + let stale = false; + + function clearExclusive() { + busy = false; + waitingForInput = false; + attention = false; + responseReady = false; + } + + function touch(event) { + if (event && event.at !== undefined) lastActivityAt = event.at; + if (event && event.source !== undefined) lastActivitySource = event.source; + } + + function apply(event) { + if (!event || typeof event.type !== 'string') return; + switch (event.type) { + case 'busy': + if (event.active) { + clearExclusive(); + busy = true; + } else { + busy = false; + waitingForInput = true; + responseReady = event.armReady !== false; + } + touch(event); + break; + case 'attention': + if (event.active === false) { + attention = false; + } else { + clearExclusive(); + attention = true; + } + touch(event); + break; + case 'clearUnread': + responseReady = false; + break; + case 'liveness': + liveness = event.value === 'alive' || event.value === 'dead' ? event.value : 'unknown'; + break; + case 'attached': + attached = !!event.value; + break; + case 'transcriptTouched': + touch({ at: event.at !== undefined ? event.at : Date.now(), source: event.source || 'transcript' }); + break; + case 'descriptorStatus': + liveness = event.status === 'alive' || event.status === 'dead' ? event.status : liveness; + touch({ at: event.at, source: 'descriptor' }); + break; + case 'subagentSpawned': + agentsBusy = true; + break; + case 'subagentCompleted': + agentsBusy = !!event.stillActive; + break; + case 'label': + label = event.value !== undefined ? event.value : label; + labelConfidence = event.confidence !== undefined ? event.confidence : labelConfidence; + break; + case 'attachable': + attachable = !!event.value; + break; + case 'archived': + archived = !!event.value; + break; + case 'stale': + stale = !!event.value; + break; + default: + break; + } + } + + function snapshot() { + return { + kind, + liveness, + attached, + busy, + waitingForInput, + attention, + responseReady, + agentsBusy, + lastActivityAt, + lastActivitySource, + label, + labelConfidence, + attachable, + archived, + stale, + }; + } + + return { apply, snapshot }; +} + +// One icon slot per row, highest rung wins — see .ai/contexts/session-state.md +function renderSessionIcon(snapshot) { + const s = snapshot || {}; + for (const rung of PRIORITY) { + if (rungActive(s, rung)) { + const icon = ICON_BY_RUNG[rung]; + return { classes: icon.classes.slice(), glyph: icon.glyph, title: icon.title }; + } + } + const idle = ICON_BY_RUNG.idle; + return { classes: idle.classes.slice(), glyph: idle.glyph, title: idle.title }; +} + +function rungActive(s, rung) { + switch (rung) { + case 'attention': return !!s.attention; + case 'responseReady': return !!s.responseReady; + case 'busy': return !!s.busy; + case 'agentsBusy': return !!s.agentsBusy; + case 'waitingForInput': return !!s.waitingForInput; + case 'idle': return false; // fallback rung, never matched directly here + case 'stale': return !!s.stale; + case 'archived': return !!s.archived; + default: return false; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { createSessionState, renderSessionIcon }; +} diff --git a/public/sidebar.js b/public/sidebar.js index ff1783a9..140bd30c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -235,7 +235,7 @@ function reflectSubagentRunningState(parentSessionId, agentId) { // session's own states (needs-attention, response-ready, cli-busy) // precedence over it. const parentEl = document.getElementById('si-' + parentSessionId); - if (parentEl) parentEl.classList.toggle('has-busy-agents', parentHasActiveSubagent(parentSessionId)); + setHasBusyAgents(parentEl, parentHasActiveSubagent(parentSessionId)); if (window.ATRACE) window.atrace('class.subagent', parentSessionId, { agentId, running, childEl: el ? el.id : null, caretEl: caret ? caret.id : null, parentEl: parentEl ? parentEl.id : null, 'has-busy-agents': parentHasActiveSubagent(parentSessionId), fn: 'reflectSubagentRunningState' }); } @@ -285,9 +285,9 @@ function buildSubagentItem(session) { item.id = 'si-' + session.sessionId; const isRunning = isSubagentActive(session.parentSessionId, session.agentId); if (isRunning) item.classList.add('running'); - if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention'); - if (responseReadySessions.has(session.sessionId)) item.classList.add('response-ready'); - if (sessionBusyState.get(session.sessionId)) item.classList.add('cli-busy'); + setNeedsAttention(item, attentionSessions.has(session.sessionId)); + setResponseReady(item, responseReadySessions.has(session.sessionId)); + setCliBusy(item, !!sessionBusyState.get(session.sessionId)); if (window.ATRACE) window.atrace('class.render', session.sessionId, { el: item.id, cls: item.className, parent: session.parentSessionId || null, agentId: session.agentId || null, fn: 'buildSubagentItem' }); item.dataset.sessionId = session.sessionId; item.dataset.subagent = '1'; @@ -1312,10 +1312,10 @@ function buildSessionItem(session) { if (session.type === 'terminal') item.classList.add('is-terminal'); if (session.archived) item.classList.add('archived-item'); if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty'); - if (attentionSessions.has(session.sessionId)) item.classList.add('needs-attention'); - if (responseReadySessions.has(session.sessionId)) item.classList.add('response-ready'); - if (sessionBusyState.get(session.sessionId)) item.classList.add('cli-busy'); - if (parentHasActiveSubagent(session.sessionId)) item.classList.add('has-busy-agents'); + setNeedsAttention(item, attentionSessions.has(session.sessionId)); + setResponseReady(item, responseReadySessions.has(session.sessionId)); + setCliBusy(item, !!sessionBusyState.get(session.sessionId)); + setHasBusyAgents(item, parentHasActiveSubagent(session.sessionId)); if (window.ATRACE && item.className !== 'session-item js-stateful') window.atrace('class.render', session.sessionId, { el: item.id, cls: item.className, fn: 'buildSessionItem' }); item.dataset.sessionId = session.sessionId; if (session.remoteAlias) item.dataset.remoteAlias = session.remoteAlias; diff --git a/test/activity-trace-renderer.test.js b/test/activity-trace-renderer.test.js index 0a82fa95..72d40bfb 100644 --- a/test/activity-trace-renderer.test.js +++ b/test/activity-trace-renderer.test.js @@ -38,7 +38,7 @@ function setup({ traceEnabled }) { Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); const ctx = dom.getInternalVMContext(); - for (const file of ['activity-trace.js', 'session-activity.js']) { + for (const file of ['activity-trace.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js']) { const full = path.join(PUBLIC_DIR, file); vm.runInContext(fs.readFileSync(full, 'utf8'), ctx, { filename: full }); } @@ -130,7 +130,7 @@ test('probe sites survive a context with no preload bridge at all', () => { const dom = new JSDOM('', { url: 'http://localhost/', runScripts: 'outside-only' }); const ctx = dom.getInternalVMContext(); Object.defineProperty(dom.window, 'activeSessionId', { value: null, writable: true, configurable: true }); - for (const file of ['activity-trace.js', 'session-activity.js']) { + for (const file of ['activity-trace.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js']) { const full = path.join(PUBLIC_DIR, file); vm.runInContext(fs.readFileSync(full, 'utf8'), ctx, { filename: full }); } diff --git a/test/dom-grid-sidebar-prune-collision.test.js b/test/dom-grid-sidebar-prune-collision.test.js index ee9f77cf..065a8264 100644 --- a/test/dom-grid-sidebar-prune-collision.test.js +++ b/test/dom-grid-sidebar-prune-collision.test.js @@ -69,6 +69,12 @@ function setupCombinedDom() { responseReadySessions: new Set(), sessionBusyState: new Map(), cachedAllProjects: [], + // public/session-activity-dom.js is not loaded in this minimal harness + // (see .ai/contexts/session-state.md) — sidebar.js calls these directly. + setNeedsAttention: (el, on) => { if (el) el.classList.toggle('needs-attention', !!on); }, + setResponseReady: (el, on) => { if (el) el.classList.toggle('response-ready', !!on); }, + setCliBusy: (el, on) => { if (el) el.classList.toggle('cli-busy', !!on); }, + setHasBusyAgents: (el, on) => { if (el) el.classList.toggle('has-busy-agents', !!on); }, pollActiveSessions: () => {}, showNewSessionPopover: () => {}, openSettingsViewer: () => {}, diff --git a/test/dom-setup.js b/test/dom-setup.js index 0a9e2028..478d14da 100644 --- a/test/dom-setup.js +++ b/test/dom-setup.js @@ -127,10 +127,12 @@ function setupSidebarDom() { evalInWindow(dom, path.join(PUBLIC_DIR, 'icons.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'subagent-timing.js')); - // session-activity.js owns attentionSessions/responseReadySessions/ - // sessionBusyState and the setActivity/applyActivityClasses/sessionItemEl - // functions sidebar.js and remote-activity-ui.js call — load order mirrors - // index.html. + // session-state.js (pure domain) + session-activity-dom.js (DOM projection) + // + session-activity.js (Maps/Sets, setActivity/purgeActivityFor) — load + // order mirrors index.html. sidebar.js and remote-activity-ui.js call + // setActivity/applyActivityClasses/sessionItemEl from these. + evalInWindow(dom, path.join(PUBLIC_DIR, 'session-state.js')); + evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity-dom.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity.js')); // sidebar.js, then remote-activity-ui.js (seedRemoteActivity, called from diff --git a/test/dom-subagent-transcript.test.js b/test/dom-subagent-transcript.test.js index 008d71a3..72b28ed5 100644 --- a/test/dom-subagent-transcript.test.js +++ b/test/dom-subagent-transcript.test.js @@ -117,6 +117,13 @@ function setupDom({ readSubagentJsonlResult = { entries: SAMPLE_ENTRIES }, readS cachedProjects: [], cachedAllProjects: [], + // public/session-activity-dom.js is not loaded in this minimal harness + // (see .ai/contexts/session-state.md) — sidebar.js calls these directly. + setNeedsAttention: (el, on) => { if (el) el.classList.toggle('needs-attention', !!on); }, + setResponseReady: (el, on) => { if (el) el.classList.toggle('response-ready', !!on); }, + setCliBusy: (el, on) => { if (el) el.classList.toggle('cli-busy', !!on); }, + setHasBusyAgents: (el, on) => { if (el) el.classList.toggle('has-busy-agents', !!on); }, + // remote-activity-ui.js is not loaded in this harness — renderProjects // calls it unconditionally, and no fixture session here is remote. seedRemoteActivity: () => {}, diff --git a/test/dom-subagent-ttl-tick.test.js b/test/dom-subagent-ttl-tick.test.js index 091ff762..f5130e04 100644 --- a/test/dom-subagent-ttl-tick.test.js +++ b/test/dom-subagent-ttl-tick.test.js @@ -66,6 +66,12 @@ function setupCombinedDom() { responseReadySessions: new Set(), sessionBusyState: new Map(), cachedAllProjects: [], + // public/session-activity-dom.js is not loaded in this minimal harness + // (see .ai/contexts/session-state.md) — sidebar.js calls these directly. + setNeedsAttention: (el, on) => { if (el) el.classList.toggle('needs-attention', !!on); }, + setResponseReady: (el, on) => { if (el) el.classList.toggle('response-ready', !!on); }, + setCliBusy: (el, on) => { if (el) el.classList.toggle('cli-busy', !!on); }, + setHasBusyAgents: (el, on) => { if (el) el.classList.toggle('has-busy-agents', !!on); }, // remote-activity-ui.js is not loaded in this harness — renderProjects // calls it unconditionally, and no fixture session here is remote. seedRemoteActivity: () => {}, diff --git a/test/remote-activity-ui.test.js b/test/remote-activity-ui.test.js index 0a834a54..e7b7e70a 100644 --- a/test/remote-activity-ui.test.js +++ b/test/remote-activity-ui.test.js @@ -21,6 +21,10 @@ const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); +// session-activity.js was split (see .ai/contexts/session-state.md); load its +// three parts in index.html order before remote-activity-ui.js. +const STATE_SRC = path.join(__dirname, '..', 'public', 'session-state.js'); +const DOM_SRC = path.join(__dirname, '..', 'public', 'session-activity-dom.js'); const ACTIVITY_SRC = path.join(__dirname, '..', 'public', 'session-activity.js'); const SRC = path.join(__dirname, '..', 'public', 'remote-activity-ui.js'); @@ -59,6 +63,8 @@ function setup(sessionIds = ['s1']) { }); const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(STATE_SRC, 'utf8'), ctx, { filename: STATE_SRC }); + vm.runInContext(fs.readFileSync(DOM_SRC, 'utf8'), ctx, { filename: DOM_SRC }); vm.runInContext(fs.readFileSync(ACTIVITY_SRC, 'utf8'), ctx, { filename: ACTIVITY_SRC }); vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); diff --git a/test/running-indicators.test.js b/test/running-indicators.test.js index fbe181f1..47d4f99b 100644 --- a/test/running-indicators.test.js +++ b/test/running-indicators.test.js @@ -464,8 +464,12 @@ test('public/app.js: pty-stop cleanup removes has-busy-agents and purges the sid assert.notEqual(scanStart, -1, 'the .session-item pty-set scan must still exist in public/app.js'); const body = src.slice(scanStart, scanStart + 1200); - assert.match(body, /classList\.remove\([^)]*'has-busy-agents'[^)]*\)/, - "the !running cleanup must remove 'has-busy-agents' along with the other per-session state classes"); + // Was a literal classList.remove('has-busy-agents', ...) before the DOM + // split in .ai/contexts/session-state.md — now routed through the + // projection file's setHasBusyAgents() (public/session-activity-dom.js), + // the only place allowed to touch this class (eslint.config.js). + assert.match(body, /setHasBusyAgents\(item,\s*false\)/, + "the !running cleanup must clear 'has-busy-agents' along with the other per-session state classes"); assert.match(body, /clearActiveSubagentsFor\(id\)/, 'the !running cleanup must purge activeSubagentsByParent via clearActiveSubagentsFor so a re-render cannot resurrect the indicator'); }); diff --git a/test/session-activity.test.js b/test/session-activity.test.js index 8c91ea7a..4380c981 100644 --- a/test/session-activity.test.js +++ b/test/session-activity.test.js @@ -17,6 +17,11 @@ const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); +// session-activity.js was split (see .ai/contexts/session-state.md): the pure +// domain module (session-state.js) and the DOM projection +// (session-activity-dom.js) load alongside it, same order as index.html. +const STATE_SRC = path.join(__dirname, '..', 'public', 'session-state.js'); +const DOM_SRC = path.join(__dirname, '..', 'public', 'session-activity-dom.js'); const SRC = path.join(__dirname, '..', 'public', 'session-activity.js'); function setup(sessionIds = ['s1', 's2']) { @@ -31,6 +36,8 @@ function setup(sessionIds = ['s1', 's2']) { Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(STATE_SRC, 'utf8'), ctx, { filename: STATE_SRC }); + vm.runInContext(fs.readFileSync(DOM_SRC, 'utf8'), ctx, { filename: DOM_SRC }); vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); const read = (expr) => vm.runInContext(expr, ctx); diff --git a/test/session-state-boundary.test.js b/test/session-state-boundary.test.js new file mode 100644 index 00000000..e66b294a --- /dev/null +++ b/test/session-state-boundary.test.js @@ -0,0 +1,35 @@ +// Boundary check for public/session-state.js: it must stay a pure domain +// module — no DOM, no IPC, no electron. Same source-grep technique as +// test/main-ctx-db-wiring.test.js (no require() of the module under test, +// since the point is to catch an accidental DOM/electron dependency before +// it ever gets a chance to run under node:test). +// See .ai/contexts/session-state.md. + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'session-state.js'), 'utf8'); + +test('session-state.js never references document', () => { + assert.ok(!/\bdocument\b/.test(SRC), 'session-state.js must not touch the DOM'); +}); + +test('session-state.js never references window', () => { + assert.ok(!/\bwindow\b/.test(SRC), 'session-state.js must not touch window'); +}); + +test("session-state.js never requires 'electron'", () => { + assert.ok(!/require\(\s*['"]electron['"]\s*\)/.test(SRC), 'session-state.js must not depend on electron'); +}); + +test('session-state.js never references ipcRenderer', () => { + assert.ok(!/\bipcRenderer\b/.test(SRC), 'session-state.js must not touch IPC directly'); +}); + +test('session-state.js keeps the dual-load module.exports guard (like public/restore-plan.js)', () => { + assert.match(SRC, /if\s*\(\s*typeof module\s*!==\s*['"]undefined['"]\s*&&\s*module\.exports\s*\)/, + 'session-state.js must stay require()-able from node:test with no DOM shim'); +}); diff --git a/test/session-state.test.js b/test/session-state.test.js new file mode 100644 index 00000000..9a15023b --- /dev/null +++ b/test/session-state.test.js @@ -0,0 +1,203 @@ +// Tests for public/session-state.js — the pure domain module introduced by +// the migration in .ai/contexts/session-state.md (steps 1-2 of issue #246). + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createSessionState, renderSessionIcon } = require('../public/session-state.js'); + +// --------------------------------------------------------------------------- +// apply-sequence -> snapshot +// --------------------------------------------------------------------------- + +test('a fresh state snapshots to the idle defaults', () => { + const s = createSessionState('local-pty'); + const snap = s.snapshot(); + assert.equal(snap.kind, 'local-pty'); + assert.equal(snap.liveness, 'unknown'); + assert.equal(snap.attached, false); + assert.equal(snap.busy, false); + assert.equal(snap.waitingForInput, false); + assert.equal(snap.attention, false); + assert.equal(snap.agentsBusy, false); +}); + +test('busy(true) -> busy(false) sequence arms waitingForInput and responseReady', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'busy', active: true }); + assert.equal(s.snapshot().busy, true); + + s.apply({ type: 'busy', active: false, armReady: true }); + const snap = s.snapshot(); + assert.equal(snap.busy, false); + assert.equal(snap.waitingForInput, true); + assert.equal(snap.responseReady, true); +}); + +test('busy(false) with armReady:false arms waitingForInput but not responseReady', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'busy', active: false, armReady: false }); + const snap = s.snapshot(); + assert.equal(snap.waitingForInput, true); + assert.equal(snap.responseReady, false); +}); + +test('clearUnread drops responseReady without touching busy', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'busy', active: false, armReady: true }); + assert.equal(s.snapshot().responseReady, true); + + s.apply({ type: 'clearUnread' }); + assert.equal(s.snapshot().responseReady, false); + assert.equal(s.snapshot().busy, false); +}); + +test('liveness/attached are tracked as two separate facts (2026-09-11 lifecycle decision)', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'liveness', value: 'alive' }); + assert.equal(s.snapshot().liveness, 'alive'); + assert.equal(s.snapshot().attached, false, 'liveness alone must not imply attached'); + + s.apply({ type: 'attached', value: true }); + assert.equal(s.snapshot().attached, true); + assert.equal(s.snapshot().liveness, 'alive', 'attaching must not change liveness'); + + s.apply({ type: 'attached', value: false }); // detach — closing the tab, not a stop + assert.equal(s.snapshot().attached, false); + assert.equal(s.snapshot().liveness, 'alive', 'a detach is not a stop: the process is still alive'); +}); + +test('subagentSpawned/Completed drive agentsBusy independently of busy/attention', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'subagentSpawned' }); + assert.equal(s.snapshot().agentsBusy, true); + s.apply({ type: 'busy', active: true }); + assert.equal(s.snapshot().agentsBusy, true, 'agentsBusy survives an unrelated busy transition'); + s.apply({ type: 'subagentCompleted', stillActive: false }); + assert.equal(s.snapshot().agentsBusy, false); +}); + +test('a malformed or unknown event is a no-op', () => { + const s = createSessionState('local-pty'); + const before = s.snapshot(); + s.apply(null); + s.apply(undefined); + s.apply({}); + s.apply({ type: 'not-a-real-event' }); + assert.deepEqual(s.snapshot(), before); +}); + +// --------------------------------------------------------------------------- +// Exclusivity — busy / waitingForInput / attention +// --------------------------------------------------------------------------- + +test('exclusivity: attention while busy clears busy and any pending unread', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'attention', active: true }); + const snap = s.snapshot(); + assert.equal(snap.attention, true); + assert.equal(snap.busy, false, 'attention wins over busy'); + assert.equal(snap.waitingForInput, false); + assert.equal(snap.responseReady, false); +}); + +test('exclusivity: going busy again clears attention and waitingForInput/responseReady', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'attention', active: true }); + s.apply({ type: 'busy', active: true }); + const snap = s.snapshot(); + assert.equal(snap.busy, true); + assert.equal(snap.attention, false); + assert.equal(snap.waitingForInput, false); +}); + +test('exclusivity: at most one of busy/waitingForInput/attention is ever true', () => { + const s = createSessionState('local-pty'); + const events = [ + { type: 'busy', active: true }, + { type: 'attention', active: true }, + { type: 'attention', active: false }, + { type: 'busy', active: false, armReady: true }, + { type: 'busy', active: true }, + { type: 'busy', active: false, armReady: false }, + { type: 'attention', active: true }, + ]; + for (const e of events) { + s.apply(e); + const snap = s.snapshot(); + const trueCount = [snap.busy, snap.waitingForInput, snap.attention].filter(Boolean).length; + assert.ok(trueCount <= 1, `busy/waitingForInput/attention must stay exclusive, got ${trueCount} true after ${JSON.stringify(e)}`); + } +}); + +// --------------------------------------------------------------------------- +// Priority order (attention > responseReady > busy > agentsBusy > +// waitingForInput > idle+age > stale > archived) +// --------------------------------------------------------------------------- + +test('priority: attention beats every other rung', () => { + const snap = { attention: true, responseReady: true, busy: true, agentsBusy: true, waitingForInput: true, stale: true, archived: true }; + assert.deepEqual(renderSessionIcon(snap).classes, ['needs-attention']); +}); + +test('priority: responseReady beats busy/agentsBusy/waitingForInput', () => { + const snap = { attention: false, responseReady: true, busy: false, agentsBusy: true, waitingForInput: true }; + assert.deepEqual(renderSessionIcon(snap).classes, ['response-ready']); +}); + +test('priority: busy beats agentsBusy and waitingForInput', () => { + const snap = { busy: true, agentsBusy: true, waitingForInput: true }; + assert.deepEqual(renderSessionIcon(snap).classes, ['cli-busy']); +}); + +test('priority: agentsBusy beats waitingForInput/stale/archived', () => { + const snap = { agentsBusy: true, waitingForInput: true, stale: true, archived: true }; + assert.deepEqual(renderSessionIcon(snap).classes, ['has-busy-agents']); +}); + +test('priority: waitingForInput beats stale/archived', () => { + const snap = { waitingForInput: true, stale: true, archived: true }; + assert.deepEqual(renderSessionIcon(snap).classes, []); + assert.equal(renderSessionIcon(snap).title, 'Waiting for input'); +}); + +test('priority: stale beats archived', () => { + const snap = { stale: true, archived: true }; + assert.equal(renderSessionIcon(snap).title, 'Stale'); +}); + +test('priority: archived is the lowest rung', () => { + const snap = { archived: true }; + assert.equal(renderSessionIcon(snap).title, 'Archived'); +}); + +test('priority: an empty snapshot resolves to idle', () => { + assert.deepEqual(renderSessionIcon({}), { classes: [], glyph: '', title: 'Idle' }); + assert.deepEqual(renderSessionIcon(undefined), { classes: [], glyph: '', title: 'Idle' }); +}); + +// --------------------------------------------------------------------------- +// renderSessionIcon for each state — one icon slot: { classes, glyph, title } +// --------------------------------------------------------------------------- + +test('renderSessionIcon: every rung returns a distinct, well-shaped icon', () => { + const cases = [ + [{ attention: true }, 'needs-attention', '!'], + [{ responseReady: true }, 'response-ready', '●'], + [{ busy: true }, 'cli-busy', '⠋'], + [{ agentsBusy: true }, 'has-busy-agents', '◆'], + ]; + for (const [snap, cls, glyph] of cases) { + const icon = renderSessionIcon(snap); + assert.ok(icon.classes.includes(cls), `expected class ${cls} for ${JSON.stringify(snap)}`); + assert.equal(icon.glyph, glyph); + assert.ok(typeof icon.title === 'string' && icon.title.length > 0); + } + // The three non-CSS rungs carry no legacy class (no sidebar HTML shape change yet). + for (const snap of [{ waitingForInput: true }, { stale: true }, { archived: true }, {}]) { + assert.deepEqual(renderSessionIcon(snap).classes, []); + } +}); diff --git a/test/sidebar-busy-agents-tint.test.js b/test/sidebar-busy-agents-tint.test.js index c056841a..aa727e86 100644 --- a/test/sidebar-busy-agents-tint.test.js +++ b/test/sidebar-busy-agents-tint.test.js @@ -113,8 +113,18 @@ test('style.css: needs-attention keeps precedence over the tinted spinner', () = 'the tint must not paint over the attention indicator'); }); -test('the tint can never collide with response-ready: applyActivityClasses keeps them exclusive', () => { - const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'session-activity.js'), 'utf8'); - assert.match(src, /toggle\('cli-busy',\s*!ready\s*&&/, - 'cli-busy is only ever set when the session is not response-ready'); +test('the tint can never collide with response-ready: renderSessionIcon keeps them exclusive', () => { + // Moved from a source-level regex pin on session-activity.js to a real + // exercise of session-state.js (the split introduced in .ai/contexts/session-state.md). + const { createSessionState, renderSessionIcon } = require('../public/session-state.js'); + const state = createSessionState('local-pty'); + + state.apply({ type: 'busy', active: true }); + state.apply({ type: 'busy', active: false, armReady: true }); // idle, unseen → response-ready + assert.ok(renderSessionIcon(state.snapshot()).classes.includes('response-ready'), 'precondition: response-ready armed'); + + state.apply({ type: 'busy', active: true }); // busy again + const classes = renderSessionIcon(state.snapshot()).classes; + assert.ok(classes.includes('cli-busy')); + assert.ok(!classes.includes('response-ready'), 'cli-busy is only ever set when the session is not response-ready'); });