diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 34f79e6d..0857b93e 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -186,6 +186,18 @@ the exact remote command, and the mutation proofs are in delete-then-insert path as the cold-start scan. Parsing 249 MB on the main thread would freeze the UI; `refreshFolder` is deliberately not the remote path. +### Remote hosts — busy spinner (issue #242) + +Remote transcript-write activity feeds the same `setActivity(sessionId, active, via)` +dispatcher in `session-activity.js` that local PTY output uses — `remote-activity-ui.js` +calls `setActivity(sessionId, true, 'remote-watch')` on each `remote-activity` IPC event +and arms a 20 s decay timer (one per session, reset on each event) that calls +`setActivity(sessionId, false, 'remote-decay')` when it fires, and `seedRemoteActivity(session)` +(called from `renderProjects`, before any row is built) applies the same call from +`session.remoteActiveAt` on first paint so a row rendered inside the decay window starts +busy without waiting for the next event; the visual is the shared `.cli-busy` braille +spinner, not a separate indicator. + ### Remote hosts file-level rescan (issue #216, first half) **The unit of rescan used to be the folder, not the file.** `syncMirror` diff --git a/eslint.config.js b/eslint.config.js index c7358ff8..f3a43016 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -141,6 +141,8 @@ const rendererCrossFileGlobals = { setActivity: 'readonly', trackActivity: 'readonly', applyActivityClasses: 'readonly', + sessionItemEl: 'readonly', + seedRemoteActivity: 'readonly', rekeyActivityState: 'readonly', reconcileBusyState: 'readonly', currentActivitySeq: 'readonly', diff --git a/main.js b/main.js index fccd70c4..277163c8 100644 --- a/main.js +++ b/main.js @@ -485,7 +485,7 @@ const remoteWatcher = createRemoteWatcher({ log }); let watchedAliases = new Set(); function onRemoteWatchEvent(alias) { remoteIndexer.refreshHostNow(alias).catch(() => {}); } -// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") +// see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)") const remoteActivityTracker = createRemoteActivityTracker({}); function onRemoteWatchActivity(alias, rel) { diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js index be5c4e81..a56526b0 100644 --- a/public/remote-activity-ui.js +++ b/public/remote-activity-ui.js @@ -1,13 +1,8 @@ -// See .ai/contexts/session-cache.md ("Remote hosts — activity pip"). +// See .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)"). const PIP_DECAY_MS = 20000; const remoteActivityDecayTimers = new Map(); -function remoteActivityDotFor(sessionId) { - const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`); - return item ? item.querySelector('.remote-activity-dot') : null; -} - function clearRemoteActivityTimer(sessionId) { const t = remoteActivityDecayTimers.get(sessionId); if (t) { @@ -16,23 +11,36 @@ function clearRemoteActivityTimer(sessionId) { } } +function armRemoteDecayTimer(sessionId, ms) { + remoteActivityDecayTimers.set(sessionId, setTimeout(() => { + remoteActivityDecayTimers.delete(sessionId); + setActivity(sessionId, false, 'remote-decay'); + }, ms)); +} + function pruneRemoteActivityTimers() { for (const sessionId of remoteActivityDecayTimers.keys()) { - if (!remoteActivityDotFor(sessionId)) clearRemoteActivityTimer(sessionId); + if (!sessionItemEl(sessionId)) clearRemoteActivityTimer(sessionId); } } function onRemoteActivityEvent(payload) { const sessionId = payload && payload.sessionId; if (typeof sessionId !== 'string' || !sessionId) return; - const dot = remoteActivityDotFor(sessionId); - if (dot) dot.classList.add('active'); + setActivity(sessionId, true, 'remote-watch'); clearRemoteActivityTimer(sessionId); - remoteActivityDecayTimers.set(sessionId, setTimeout(() => { - remoteActivityDecayTimers.delete(sessionId); - const el = remoteActivityDotFor(sessionId); - if (el) el.classList.remove('active'); - }, PIP_DECAY_MS)); + armRemoteDecayTimer(sessionId, PIP_DECAY_MS); +} + +function seedRemoteActivity(session) { + if (!session || !session.remoteAlias) return; + if (!Number.isFinite(session.remoteActiveAt)) return; + const sessionId = session.sessionId; + const remaining = session.remoteActiveAt + PIP_DECAY_MS - Date.now(); + if (remaining <= 0) return; + setActivity(sessionId, true, 'remote-seed'); + if (remoteActivityDecayTimers.has(sessionId)) return; + armRemoteDecayTimer(sessionId, remaining); } window.api.onRemoteActivity(onRemoteActivityEvent); diff --git a/public/sidebar.js b/public/sidebar.js index a169baeb..9c9b2c8b 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -9,9 +9,6 @@ // showNewSessionPopover, openSettingsViewer, showResumeSessionDialog, // showJsonlViewer, forkSession, openSession, loadProjects (app.js/dialogs.js) -// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") -const REMOTE_ACTIVITY_DECAY_MS = 20000; - function slugId(slug) { return 'slug-' + slug.replace(/[^a-zA-Z0-9_-]/g, '_'); } @@ -522,6 +519,10 @@ function buildSlugGroup(slug, sessions, subagentIndex) { function renderProjects(projects, resort) { pruneStaleSubagents(); pendingSubagentRest.clear(); + // see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)") + for (const project of projects) { + for (const session of project.sessions) seedRemoteActivity(session); + } const newSidebar = document.createElement('div'); // Sort project groups using sortedOrder as source of truth @@ -1302,16 +1303,6 @@ function buildSessionItem(session) { const dot = document.createElement('span'); dot.className = 'session-status-dot' + (activePtyIds.has(session.sessionId) ? ' running' : ''); - // see .ai/contexts/session-cache.md ("Remote hosts — activity pip") - let activityDot = null; - if (session.remoteAlias) { - activityDot = document.createElement('span'); - const isActive = Number.isFinite(session.remoteActiveAt) && - (Date.now() - session.remoteActiveAt) < REMOTE_ACTIVITY_DECAY_MS; - activityDot.className = 'session-status-dot remote-activity-dot' + (isActive ? ' active' : ''); - activityDot.title = 'Remote session is writing its transcript'; - } - // Info block const info = document.createElement('div'); info.className = 'session-info'; @@ -1408,7 +1399,6 @@ function buildSessionItem(session) { row.appendChild(pin); row.appendChild(dot); - if (activityDot) row.appendChild(activityDot); row.appendChild(info); row.appendChild(actions); item.appendChild(row); diff --git a/public/style.css b/public/style.css index c83ddc16..d07b5167 100644 --- a/public/style.css +++ b/public/style.css @@ -969,22 +969,6 @@ body { display: flex; flex-direction: column; } background: #3ecf5a; } -/* see .ai/contexts/session-cache.md ("Remote hosts — activity pip") */ -.remote-activity-dot { - background: transparent; - margin-left: -4px; -} - -.remote-activity-dot.active { - background: #b388ff; - animation: remote-activity-pulse 1s ease-in-out infinite; -} - -@keyframes remote-activity-pulse { - 0%, 100% { opacity: 0.45; transform: scale(0.85); } - 50% { opacity: 1; transform: scale(1.2); } -} - /* ---- CLI busy spinner (braille spinner detected) ---- */ /* needs-attention takes precedence — when both are set, the attention indicator shows */ /* Braille spinner via content keyframes — see docs/decisions/0002 */ diff --git a/remote-activity.js b/remote-activity.js index 22cb5a6b..df3789db 100644 --- a/remote-activity.js +++ b/remote-activity.js @@ -1,4 +1,4 @@ -// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") +// see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)") 'use strict'; const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/test/dom-setup.js b/test/dom-setup.js index c4966973..e0f276c7 100644 --- a/test/dom-setup.js +++ b/test/dom-setup.js @@ -82,9 +82,10 @@ function setupSidebarDom() { showTodayOnly: false, visibleSessionCount: 10, sessionMaxAgeDays: 3650, // generous so fixtures aren't filtered out by age - attentionSessions: new Set(), - responseReadySessions: new Set(), - sessionBusyState: new Map(), + // attentionSessions / responseReadySessions / sessionBusyState come from + // the real session-activity.js (evaluated below), not a stub here — that + // module owns them, and sidebar.js/remote-activity-ui.js must see the + // exact same Maps/Sets it mutates. cachedProjects: [], cachedAllProjects: [], @@ -121,8 +122,19 @@ function setupSidebarDom() { evalInWindow(dom, path.join(PUBLIC_DIR, 'icons.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'subagent-timing.js')); - // Finally, sidebar.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. + evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity.js')); + + // sidebar.js, then remote-activity-ui.js (seedRemoteActivity, called from + // renderProjects). evalInWindow(dom, path.join(PUBLIC_DIR, 'sidebar.js')); + evalInWindow(dom, path.join(PUBLIC_DIR, 'remote-activity-ui.js')); + + const ctx = dom.getInternalVMContext(); + const read = (expr) => vm.runInContext(expr, ctx); return { window, @@ -134,6 +146,12 @@ function setupSidebarDom() { folderId: window.folderId, showDeleteSessionDialog: window.showDeleteSessionDialog, }, + // The real state owned by session-activity.js — same objects sidebar.js + // and remote-activity-ui.js read/mutate as bare identifiers. + sessionBusyState: read('sessionBusyState'), + attentionSessions: read('attentionSessions'), + responseReadySessions: read('responseReadySessions'), + setActivity: read('setActivity'), // Simulate the main process emitting subagent-spawned/subagent-completed // (session-transitions.js) by invoking the callback sidebar.js registered // via window.api.onSubagentSpawned/onSubagentCompleted at eval time. diff --git a/test/dom-sidebar-remote-activity-pip.test.js b/test/dom-sidebar-remote-activity-pip.test.js index bd5fe13e..0f833bbd 100644 --- a/test/dom-sidebar-remote-activity-pip.test.js +++ b/test/dom-sidebar-remote-activity-pip.test.js @@ -1,8 +1,9 @@ -// Issue #242: a rebuilt sidebar (or a fresh launch) must paint the remote -// activity pip from session.remoteActiveAt without waiting for the next -// live remote-activity IPC message — see .ai/contexts/session-cache.md -// ("Remote hosts — activity pip"). The live-update path itself is covered -// by test/remote-activity-ui.test.js. +// Issue #242/#243: a rebuilt sidebar (or a fresh launch) must paint a remote +// session busy — the same braille spinner a local session gets — straight +// from session.remoteActiveAt, without waiting for the next live +// remote-activity IPC message. See .ai/contexts/session-cache.md +// ("Remote hosts — busy spinner (issue #242)"). The live-update path itself +// is covered by test/remote-activity-ui.test.js. const test = require('node:test'); const assert = require('node:assert/strict'); @@ -18,7 +19,41 @@ function remoteProject(session) { }); } -test('a session active within the decay window paints the pip lit on first render', () => { +// A render function must not arm timers itself — seedRemoteActivity does, +// and this fake clock drives that timer by hand instead of a real wall-clock +// wait. Installed on ctx.window BEFORE renderProjects() so the seed's +// setTimeout call picks it up. +function installFakeTimers(win) { + let elapsed = 0; + const timers = []; + let nextId = 1; + Object.defineProperty(win, 'setTimeout', { + value: (fn, ms) => { + const t = { id: nextId++, at: elapsed + ms, fn, cleared: false, fired: false }; + timers.push(t); + return t.id; + }, + writable: true, configurable: true, + }); + Object.defineProperty(win, 'clearTimeout', { + value: (id) => { + const t = timers.find(t => t.id === id); + if (t) t.cleared = true; + }, + writable: true, configurable: true, + }); + return { + advance(ms) { + elapsed += ms; + for (const t of timers) { + if (!t.cleared && !t.fired && t.at <= elapsed) { t.fired = true; t.fn(); } + } + }, + pendingCount: () => timers.filter(t => !t.cleared && !t.fired).length, + }; +} + +test('a session active within the decay window renders busy on first render', () => { const ctx = setupSidebarDom(); try { const session = { @@ -28,13 +63,13 @@ test('a session active within the decay window paints the pip lit on first rende }; ctx.sidebar.renderProjects([remoteProject(session)], true); - const dot = ctx.document.querySelector('#si-remote-active .remote-activity-dot'); - assert.ok(dot, 'a remote session must carry the activity pip element'); - assert.ok(dot.classList.contains('active'), 'a sighting 5s ago is still inside the 20s decay window'); + const item = ctx.document.querySelector('#si-remote-active'); + assert.ok(item, 'the session row must exist'); + assert.ok(item.classList.contains('cli-busy'), 'a sighting 5s ago is still inside the 20s decay window'); } finally { ctx.destroy(); } }); -test('a session last active past the decay window renders the pip off', () => { +test('a session last active past the decay window renders idle', () => { const ctx = setupSidebarDom(); try { const session = { @@ -44,13 +79,13 @@ test('a session last active past the decay window renders the pip off', () => { }; ctx.sidebar.renderProjects([remoteProject(session)], true); - const dot = ctx.document.querySelector('#si-remote-stale .remote-activity-dot'); - assert.ok(dot); - assert.ok(!dot.classList.contains('active'), 'a sighting a minute ago is well past the 20s decay window'); + const item = ctx.document.querySelector('#si-remote-stale'); + assert.ok(item); + assert.ok(!item.classList.contains('cli-busy'), 'a sighting a minute ago is well past the 20s decay window'); } finally { ctx.destroy(); } }); -test('a session with no remoteActiveAt at all renders the pip off, not crashing on undefined', () => { +test('a session with no remoteActiveAt at all renders idle, not crashing on undefined', () => { const ctx = setupSidebarDom(); try { const session = { @@ -60,13 +95,27 @@ test('a session with no remoteActiveAt at all renders the pip off, not crashing }; ctx.sidebar.renderProjects([remoteProject(session)], true); - const dot = ctx.document.querySelector('#si-remote-never .remote-activity-dot'); - assert.ok(dot); - assert.ok(!dot.classList.contains('active')); + const item = ctx.document.querySelector('#si-remote-never'); + assert.ok(item); + assert.ok(!item.classList.contains('cli-busy')); + } finally { ctx.destroy(); } +}); + +test('no .remote-activity-dot element remains anywhere in the DOM', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-active', summary: 'live now', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 5000, + }; + ctx.sidebar.renderProjects([remoteProject(session)], true); + + assert.equal(ctx.document.querySelector('.remote-activity-dot'), null); } finally { ctx.destroy(); } }); -test('a local session carries no activity pip at all', () => { +test('a local session is not marked busy by the remote paint path', () => { const ctx = setupSidebarDom(); try { const project = makeSampleProject({ @@ -77,6 +126,51 @@ test('a local session carries no activity pip at all', () => { }); ctx.sidebar.renderProjects([project], true); - assert.equal(ctx.document.querySelector('#si-local-1 .remote-activity-dot'), null); + const item = ctx.document.querySelector('#si-local-1'); + assert.ok(item); + assert.ok(!item.classList.contains('cli-busy')); + } finally { ctx.destroy(); } +}); + +test('a seeded remote session goes idle once the remaining decay window elapses, and not before', () => { + const ctx = setupSidebarDom(); + try { + const timers = installFakeTimers(ctx.window); + const session = { + sessionId: 'remote-partial', summary: 'partially aged', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 15000, + }; + ctx.sidebar.renderProjects([remoteProject(session)], true); + + let item = ctx.document.querySelector('#si-remote-partial'); + assert.ok(item.classList.contains('cli-busy'), 'seeded busy on first render (15s old, still inside 20s)'); + + timers.advance(4000); // total 4000ms — remaining window (~5000ms) not yet elapsed + item = ctx.document.querySelector('#si-remote-partial'); + assert.ok(item.classList.contains('cli-busy'), 'must not decay before the remaining window elapses'); + + timers.advance(1001); // total 5001ms — past the ~5000ms remaining window + item = ctx.document.querySelector('#si-remote-partial'); + assert.ok(!item.classList.contains('cli-busy'), 'must go idle once the remaining window elapses'); + } finally { ctx.destroy(); } +}); + +test('seeding the same still-active session again does not stack a second decay timer', () => { + const ctx = setupSidebarDom(); + try { + const timers = installFakeTimers(ctx.window); + const session = { + sessionId: 'remote-rerender', summary: 'live now', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 5000, + }; + const project = remoteProject(session); + + ctx.sidebar.renderProjects([project], true); + assert.equal(timers.pendingCount(), 1, 'exactly one decay timer armed on first seed'); + + ctx.sidebar.renderProjects([project], false); // re-render, same session data + assert.equal(timers.pendingCount(), 1, 'a repeat seed must not arm a second timer for the same session'); } finally { ctx.destroy(); } }); diff --git a/test/dom-subagent-transcript.test.js b/test/dom-subagent-transcript.test.js index 20d884c5..008d71a3 100644 --- a/test/dom-subagent-transcript.test.js +++ b/test/dom-subagent-transcript.test.js @@ -117,6 +117,10 @@ function setupDom({ readSubagentJsonlResult = { entries: SAMPLE_ENTRIES }, readS cachedProjects: [], cachedAllProjects: [], + // remote-activity-ui.js is not loaded in this harness — renderProjects + // calls it unconditionally, and no fixture session here is remote. + seedRemoteActivity: () => {}, + // No-op function stubs (sidebar.js wires these in rebindSidebarEvents; // the real spies for openSession / showSubagentTranscript are installed // AFTER eval'ing sidebar.js + jsonl-viewer.js so the JS files don't diff --git a/test/dom-subagent-ttl-tick.test.js b/test/dom-subagent-ttl-tick.test.js index b5393d43..091ff762 100644 --- a/test/dom-subagent-ttl-tick.test.js +++ b/test/dom-subagent-ttl-tick.test.js @@ -66,6 +66,9 @@ function setupCombinedDom() { responseReadySessions: new Set(), sessionBusyState: new Map(), cachedAllProjects: [], + // remote-activity-ui.js is not loaded in this harness — renderProjects + // calls it unconditionally, and no fixture session here is remote. + seedRemoteActivity: () => {}, pollActiveSessions: () => {}, showNewSessionPopover: () => {}, openSettingsViewer: () => {}, diff --git a/test/remote-activity-ui.test.js b/test/remote-activity-ui.test.js index b0ea4f15..1f2338d5 100644 --- a/test/remote-activity-ui.test.js +++ b/test/remote-activity-ui.test.js @@ -1,8 +1,14 @@ // Tests for public/remote-activity-ui.js — the sidebar's remote -// transcript-write pip. Evaluated standalone in jsdom, same technique as -// test/session-activity.test.js: this file was split out precisely so it can -// be exercised without the rest of app.js (which builds ViewerPanel/xterm at -// module scope and cannot be eval'd in isolation). +// transcript-write signal. Since #243 it goes through the same central +// dispatcher local PTY output uses: session-activity.js's +// setActivity(sessionId, active, via), which owns sessionBusyState, +// activitySeq, the response-ready transition and the trace — never a direct +// write. See .ai/contexts/session-cache.md ("Remote hosts — busy spinner +// (issue #242)"). Evaluated standalone in jsdom together with the real +// session-activity.js, same technique as test/session-activity.test.js: +// split out precisely so it can be exercised without the rest of app.js +// (which builds ViewerPanel/xterm at module scope and cannot be eval'd in +// isolation). // // setTimeout/clearTimeout are stubbed on the jsdom window so the 20s decay // is driven by hand instead of a real wall-clock wait. @@ -15,16 +21,19 @@ const path = require('node:path'); const vm = require('node:vm'); const { JSDOM } = require('jsdom'); +const ACTIVITY_SRC = path.join(__dirname, '..', 'public', 'session-activity.js'); const SRC = path.join(__dirname, '..', 'public', 'remote-activity-ui.js'); function setup(sessionIds = ['s1']) { const items = sessionIds - .map(id => `
`) + .map(id => `
`) .join(''); const dom = new JSDOM(`${items}`, { url: 'http://localhost/', runScripts: 'outside-only' }); const { window } = dom; + Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + let onRemoteActivityCb = null; Object.defineProperty(window, 'api', { value: { onRemoteActivity: (cb) => { onRemoteActivityCb = cb; } }, @@ -50,6 +59,7 @@ function setup(sessionIds = ['s1']) { }); const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(ACTIVITY_SRC, 'utf8'), ctx, { filename: ACTIVITY_SRC }); vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); const read = (expr) => vm.runInContext(expr, ctx); @@ -58,23 +68,25 @@ function setup(sessionIds = ['s1']) { window, document: window.document, item: (id) => window.document.querySelector(`.session-item[data-session-id="${id}"]`), - dot: (id) => window.document.querySelector(`.session-item[data-session-id="${id}"] .remote-activity-dot`), emit: (payload) => onRemoteActivityCb(payload), scheduled, pending: () => scheduled.filter(h => !h.cleared), pruneRemoteActivityTimers: read('pruneRemoteActivityTimers'), remoteActivityDecayTimers: read('remoteActivityDecayTimers'), + sessionBusyState: read('sessionBusyState'), + responseReadySessions: read('responseReadySessions'), destroy: () => window.close(), }; } -test('a remote-activity event marks the matching row active', () => { +test('a remote-activity event marks the matching row busy through setActivity', () => { const t = setup(['s1']); - assert.ok(!t.dot('s1').classList.contains('active'), 'precondition: pip starts off'); + assert.ok(!t.item('s1').classList.contains('cli-busy'), 'precondition: row starts idle'); t.emit({ alias: 'vps', sessionId: 's1', at: Date.now() }); - assert.ok(t.dot('s1').classList.contains('active'), 'the pip must light up on activity'); + assert.equal(t.sessionBusyState.get('s1'), true, 'sessionBusyState is set through the central dispatcher'); + assert.ok(t.item('s1').classList.contains('cli-busy'), 'the row must go busy, same as a local session'); t.destroy(); }); @@ -84,25 +96,38 @@ test('a payload with no sessionId is ignored', () => { t.emit(null); t.emit(undefined); - assert.ok(!t.dot('s1').classList.contains('active')); + assert.ok(!t.item('s1').classList.contains('cli-busy')); assert.equal(t.scheduled.length, 0, 'a malformed payload must not schedule a decay timer either'); t.destroy(); }); -test('the pip clears once the decay timer fires, and not before', () => { +test('.cli-busy clears once the decay timer fires, and not before', () => { const t = setup(['s1']); t.emit({ sessionId: 's1' }); - assert.ok(t.dot('s1').classList.contains('active')); + assert.ok(t.item('s1').classList.contains('cli-busy')); const timer = t.pending()[0]; assert.ok(timer, 'a decay timer must be scheduled'); assert.equal(timer.ms, 20000, 'the decay window is 20s'); - // Before the timer fires, the pip stays lit. - assert.ok(t.dot('s1').classList.contains('active')); + // Before the timer fires, the row stays busy. + assert.ok(t.item('s1').classList.contains('cli-busy')); timer.fn(); // simulate the 20s elapsing - assert.ok(!t.dot('s1').classList.contains('active'), 'the pip must clear once the decay window elapses'); + assert.ok(!t.item('s1').classList.contains('cli-busy'), 'the row must go idle once the decay window elapses'); + t.destroy(); +}); + +test('decay routes through setActivity: a non-selected remote session lands in responseReadySessions, exactly like a local one', () => { + const t = setup(['s1']); + t.window.activeSessionId = 's2'; // s1 is not the focused session + t.emit({ sessionId: 's1' }); + assert.ok(!t.responseReadySessions.has('s1'), 'precondition: not response-ready while busy'); + + t.pending()[0].fn(); // decay fires + + assert.equal(t.sessionBusyState.get('s1'), false); + assert.ok(t.responseReadySessions.has('s1'), 'going idle through setActivity marks the turn as an unread response, like a local session'); t.destroy(); }); @@ -118,10 +143,10 @@ test('a second event before decay resets the timer instead of stacking one', () // Firing the (now-cancelled) first timer's callback would be a stale fire // in the real world — clearTimeout prevents that from ever happening — but - // confirm the surviving timer is the one that actually clears the pip. + // confirm the surviving timer is the one that actually clears the row. const second = t.pending()[0]; second.fn(); - assert.ok(!t.dot('s1').classList.contains('active')); + assert.ok(!t.item('s1').classList.contains('cli-busy')); t.destroy(); }); diff --git a/test/remote-activity.test.js b/test/remote-activity.test.js index a353b8bd..88c5ba6b 100644 --- a/test/remote-activity.test.js +++ b/test/remote-activity.test.js @@ -1,8 +1,8 @@ 'use strict'; // remote-activity.js: the uncoalesced transcript-write signal that feeds the -// sidebar's activity pip. Properties proven here (issue #242, see -// .ai/contexts/session-cache.md, "Remote hosts — activity pip"): +// sidebar's busy spinner. Properties proven here (issue #242, see +// .ai/contexts/session-cache.md, "Remote hosts — busy spinner (issue #242)"): // 1. A rel that doesn't decode to a plausible session id is dropped. // 2. A first sighting of a session is always forwarded. // 3. A second sighting inside the throttle window is swallowed, not forwarded. diff --git a/test/sidebar-busy-agents-tint.test.js b/test/sidebar-busy-agents-tint.test.js index 640eb92c..c056841a 100644 --- a/test/sidebar-busy-agents-tint.test.js +++ b/test/sidebar-busy-agents-tint.test.js @@ -42,7 +42,7 @@ function projectWithLiveSubagent() { test('a busy session with no subagents carries cli-busy alone', () => { const ctx = setupSidebarDom(); try { - ctx.window.sessionBusyState.set('s-top-1', true); + ctx.sessionBusyState.set('s-top-1', true); ctx.sidebar.renderProjects([projectWithLiveSubagent()], true); const parent = ctx.document.getElementById('si-s-top-1'); @@ -56,7 +56,7 @@ test('a busy session with no subagents carries cli-busy alone', () => { test('a busy session with live subagents carries cli-busy AND has-busy-agents at once', () => { const ctx = setupSidebarDom(); try { - ctx.window.sessionBusyState.set('s-top-1', true); + ctx.sessionBusyState.set('s-top-1', true); ctx.sidebar.renderProjects([projectWithLiveSubagent()], true); ctx.emitSubagentSpawned({ parentSessionId: 's-top-1', agentId: 'agent-1', subagentType: 'explore' });