diff --git a/eslint.config.js b/eslint.config.js index 7901729c..c7358ff8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -145,6 +145,7 @@ const rendererCrossFileGlobals = { reconcileBusyState: 'readonly', currentActivitySeq: 'readonly', forgetActivitySeq: 'readonly', + pruneRemoteActivityTimers: 'readonly', // Third-party renderer libs loaded as + diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js new file mode 100644 index 00000000..be5c4e81 --- /dev/null +++ b/public/remote-activity-ui.js @@ -0,0 +1,38 @@ +// See .ai/contexts/session-cache.md ("Remote hosts — activity pip"). + +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) { + clearTimeout(t); + remoteActivityDecayTimers.delete(sessionId); + } +} + +function pruneRemoteActivityTimers() { + for (const sessionId of remoteActivityDecayTimers.keys()) { + if (!remoteActivityDotFor(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'); + clearRemoteActivityTimer(sessionId); + remoteActivityDecayTimers.set(sessionId, setTimeout(() => { + remoteActivityDecayTimers.delete(sessionId); + const el = remoteActivityDotFor(sessionId); + if (el) el.classList.remove('active'); + }, PIP_DECAY_MS)); +} + +window.api.onRemoteActivity(onRemoteActivityEvent); diff --git a/public/sidebar.js b/public/sidebar.js index 278c27c4..a169baeb 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -9,6 +9,9 @@ // 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, '_'); } @@ -1299,6 +1302,16 @@ 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'; @@ -1395,6 +1408,7 @@ 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 d07b5167..c83ddc16 100644 --- a/public/style.css +++ b/public/style.css @@ -969,6 +969,22 @@ 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 new file mode 100644 index 00000000..22cb5a6b --- /dev/null +++ b/remote-activity.js @@ -0,0 +1,61 @@ +// see .ai/contexts/session-cache.md ("Remote hosts — activity pip") +'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; +const DEFAULT_DECAY_MS = 20000; +const DEFAULT_IPC_MIN_MS = 1000; + +function sessionIdFromRel(rel) { + const base = (typeof rel === 'string' ? rel : '').split('/').pop() || ''; + const sessionId = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base; + return SESSION_ID_RE.test(sessionId) ? sessionId : null; +} + +function createRemoteActivityTracker(opts = {}) { + const decayMs = opts.decayMs || DEFAULT_DECAY_MS; + const ipcMinMs = opts.ipcMinMs || DEFAULT_IPC_MIN_MS; + const now = opts.now || Date.now; + + const seenAt = new Map(); + const ipcAt = new Map(); + + function key(alias, sessionId) { + return alias + ' ' + sessionId; + } + + function prune(t) { + for (const [k, at] of seenAt) { + if (t - at > decayMs) seenAt.delete(k); + } + for (const [k, at] of ipcAt) { + if (t - at > decayMs) ipcAt.delete(k); + } + } + + function record(alias, rel) { + const sessionId = sessionIdFromRel(rel); + if (!sessionId) return null; + const t = now(); + const k = key(alias, sessionId); + seenAt.set(k, t); + prune(t); + const lastIpc = ipcAt.has(k) ? ipcAt.get(k) : -Infinity; + if (t - lastIpc < ipcMinMs) return null; + ipcAt.set(k, t); + return { alias, sessionId, at: t }; + } + + function activeAt(alias, sessionId) { + prune(now()); + const k = key(alias, sessionId); + return seenAt.has(k) ? seenAt.get(k) : null; + } + + function stats() { + return { seen: seenAt.size, ipc: ipcAt.size }; + } + + return { record, activeAt, stats }; +} + +module.exports = { createRemoteActivityTracker, sessionIdFromRel, SESSION_ID_RE }; diff --git a/remote-watch.js b/remote-watch.js index 72285f19..e0b6252c 100644 --- a/remote-watch.js +++ b/remote-watch.js @@ -86,6 +86,7 @@ function createRemoteWatcher(opts = {}) { } const parsed = parseWatchLine(line); if (!parsed) return; + if (parsed.kind === 'project' && s.onActivity) s.onActivity(s.alias, parsed.rel); emitCoalesced(s, parsed.kind); } @@ -140,7 +141,7 @@ function createRemoteWatcher(opts = {}) { if (!s) { s = { alias, child: null, buf: '', stopped: true, unwatchable: false, - failures: 0, spawnedAt: 0, restartTimer: null, onEvent: null, + failures: 0, spawnedAt: 0, restartTimer: null, onEvent: null, onActivity: null, cooldown: { project: false, session: false }, pending: { project: false, session: false }, }; @@ -149,7 +150,7 @@ function createRemoteWatcher(opts = {}) { return s; } - function start(alias, onEvent) { + function start(alias, onEvent, onActivity) { if (typeof alias !== 'string' || !alias || typeof onEvent !== 'function') return; const s = getState(alias); if (!s.stopped && !s.unwatchable) return; @@ -157,6 +158,7 @@ function createRemoteWatcher(opts = {}) { s.unwatchable = false; s.failures = 0; s.onEvent = onEvent; + s.onActivity = typeof onActivity === 'function' ? onActivity : null; spawnChild(s); } diff --git a/test/dom-sidebar-remote-activity-pip.test.js b/test/dom-sidebar-remote-activity-pip.test.js new file mode 100644 index 00000000..bd5fe13e --- /dev/null +++ b/test/dom-sidebar-remote-activity-pip.test.js @@ -0,0 +1,82 @@ +// 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. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function remoteProject(session) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + sessions: [session], + }); +} + +test('a session active within the decay window paints the pip lit on first render', () => { + 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); + + 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'); + } finally { ctx.destroy(); } +}); + +test('a session last active past the decay window renders the pip off', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-stale', summary: 'quiet now', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', remoteActiveAt: Date.now() - 60000, + }; + 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'); + } finally { ctx.destroy(); } +}); + +test('a session with no remoteActiveAt at all renders the pip off, not crashing on undefined', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'remote-never', summary: 'never seen writing', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 1, + remoteAlias: 'planificator', + }; + 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')); + } finally { ctx.destroy(); } +}); + +test('a local session carries no activity pip at all', () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject({ + sessions: [{ + sessionId: 'local-1', summary: 'local work', modified: '2026-09-06T10:00:00.000Z', + starred: false, archived: 0, messageCount: 2, + }], + }); + ctx.sidebar.renderProjects([project], true); + + assert.equal(ctx.document.querySelector('#si-local-1 .remote-activity-dot'), null); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-activity-ui.test.js b/test/remote-activity-ui.test.js new file mode 100644 index 00000000..b0ea4f15 --- /dev/null +++ b/test/remote-activity-ui.test.js @@ -0,0 +1,140 @@ +// 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). +// +// setTimeout/clearTimeout are stubbed on the jsdom window so the 20s decay +// is driven by hand instead of a real wall-clock wait. + +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const SRC = path.join(__dirname, '..', 'public', 'remote-activity-ui.js'); + +function setup(sessionIds = ['s1']) { + const items = sessionIds + .map(id => `
`) + .join(''); + const dom = new JSDOM(`${items}`, + { url: 'http://localhost/', runScripts: 'outside-only' }); + const { window } = dom; + + let onRemoteActivityCb = null; + Object.defineProperty(window, 'api', { + value: { onRemoteActivity: (cb) => { onRemoteActivityCb = cb; } }, + writable: true, configurable: true, + }); + + const scheduled = []; + let nextId = 1; + Object.defineProperty(window, 'setTimeout', { + value: (fn, ms) => { + const handle = { id: nextId++, fn, ms, cleared: false }; + scheduled.push(handle); + return handle.id; + }, + writable: true, configurable: true, + }); + Object.defineProperty(window, 'clearTimeout', { + value: (id) => { + const h = scheduled.find(s => s.id === id); + if (h) h.cleared = true; + }, + writable: true, configurable: true, + }); + + const ctx = dom.getInternalVMContext(); + vm.runInContext(fs.readFileSync(SRC, 'utf8'), ctx, { filename: SRC }); + + const read = (expr) => vm.runInContext(expr, ctx); + + return { + 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'), + destroy: () => window.close(), + }; +} + +test('a remote-activity event marks the matching row active', () => { + const t = setup(['s1']); + assert.ok(!t.dot('s1').classList.contains('active'), 'precondition: pip starts off'); + + t.emit({ alias: 'vps', sessionId: 's1', at: Date.now() }); + + assert.ok(t.dot('s1').classList.contains('active'), 'the pip must light up on activity'); + t.destroy(); +}); + +test('a payload with no sessionId is ignored', () => { + const t = setup(['s1']); + t.emit({ alias: 'vps' }); + t.emit(null); + t.emit(undefined); + + assert.ok(!t.dot('s1').classList.contains('active')); + 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', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1' }); + assert.ok(t.dot('s1').classList.contains('active')); + + 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')); + + timer.fn(); // simulate the 20s elapsing + assert.ok(!t.dot('s1').classList.contains('active'), 'the pip must clear once the decay window elapses'); + t.destroy(); +}); + +test('a second event before decay resets the timer instead of stacking one', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1' }); + const first = t.pending()[0]; + + t.emit({ sessionId: 's1' }); + + assert.equal(first.cleared, true, 'the earlier timer must be cancelled, not left to also fire'); + assert.equal(t.pending().length, 1, 'exactly one live decay timer per session'); + + // 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. + const second = t.pending()[0]; + second.fn(); + assert.ok(!t.dot('s1').classList.contains('active')); + t.destroy(); +}); + +test('pruneRemoteActivityTimers cancels a timer whose row no longer exists', () => { + const t = setup(['s1', 's2']); + t.emit({ sessionId: 's1' }); + t.emit({ sessionId: 's2' }); + assert.equal(t.pending().length, 2); + + t.item('s2').remove(); // e.g. the session was archived out of the sidebar + t.pruneRemoteActivityTimers(); + + assert.equal(t.remoteActivityDecayTimers.has('s2'), false, 'the orphaned timer entry must be dropped'); + assert.equal(t.remoteActivityDecayTimers.has('s1'), true, 'a timer for a row that still exists must survive the sweep'); + t.destroy(); +}); diff --git a/test/remote-activity.test.js b/test/remote-activity.test.js new file mode 100644 index 00000000..a353b8bd --- /dev/null +++ b/test/remote-activity.test.js @@ -0,0 +1,98 @@ +'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"): +// 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. +// 4. Once the throttle window has passed, the next sighting is forwarded again. +// 5. activeAt reports null once a session has been silent past the decay window. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createRemoteActivityTracker, sessionIdFromRel } = require('../remote-activity'); +const { REMOTE_PROJECTS_REL } = require('../remote-transport'); + +const UUID = '11111111-1111-4111-8111-111111111111'; + +function clock(startAt = 1000) { + let t = startAt; + return { now: () => t, advance: (ms) => { t += ms; } }; +} + +test('sessionIdFromRel accepts a bare session transcript, rejects a subagent leg', () => { + assert.equal(sessionIdFromRel(`-srv-a/${UUID}.jsonl`), UUID); + assert.equal(sessionIdFromRel(`-srv-a/${UUID}/subagents/agent-7.jsonl`), null, + 'the basename here is "agent-7", not a session id — must not be guessed at'); + assert.equal(sessionIdFromRel('-srv-a/not-a-uuid.jsonl'), null); + assert.equal(sessionIdFromRel(''), null); + assert.equal(sessionIdFromRel(undefined), null); +}); + +test('record() drops a malformed rel instead of forwarding a guess', () => { + const tracker = createRemoteActivityTracker({ now: clock().now }); + assert.equal(tracker.record('vps', `${REMOTE_PROJECTS_REL}/-srv-a/agent-7.jsonl`), null); + assert.equal(tracker.activeAt('vps', 'agent-7'), null, 'nothing must have been recorded for the rejected id'); +}); + +test('record() forwards the first sighting of a session', () => { + const c = clock(5000); + const tracker = createRemoteActivityTracker({ now: c.now }); + const result = tracker.record('vps', `-srv-a/${UUID}.jsonl`); + assert.deepEqual(result, { alias: 'vps', sessionId: UUID, at: 5000 }); +}); + +test('record() throttles a second sighting inside the 1s window, but keeps activeAt fresh', () => { + const c = clock(0); + const tracker = createRemoteActivityTracker({ now: c.now, ipcMinMs: 1000 }); + const rel = `-srv-a/${UUID}.jsonl`; + + assert.ok(tracker.record('vps', rel), 'first sighting always forwards'); + c.advance(400); + assert.equal(tracker.record('vps', rel), null, 'a sighting inside the throttle window must not forward again'); + // The suppressed sighting still counts as activity for the decay clock. + assert.equal(tracker.activeAt('vps', UUID), 400); + + c.advance(700); // total 1100ms since the first forward + const third = tracker.record('vps', rel); + assert.ok(third, 'once ipcMinMs has elapsed since the last forward, the next sighting forwards again'); + assert.equal(third.at, 1100); +}); + +test('activeAt reports null once a session has gone silent past the decay window', () => { + const c = clock(0); + const tracker = createRemoteActivityTracker({ now: c.now, decayMs: 20000 }); + tracker.record('vps', `-srv-a/${UUID}.jsonl`); + + c.advance(19999); + assert.equal(tracker.activeAt('vps', UUID), 0, 'still inside the decay window'); + + c.advance(2); // 20001ms since the sighting + assert.equal(tracker.activeAt('vps', UUID), null, 'past the decay window, the pip must clear'); +}); + +test('two hosts never share activity state for the same session id', () => { + const tracker = createRemoteActivityTracker({ now: clock(0).now }); + tracker.record('vps-a', `-srv-a/${UUID}.jsonl`); + assert.equal(tracker.activeAt('vps-b', UUID), null, 'alias must be part of the key, not just the session id'); +}); + +test('neither map grows without bound: both are pruned past the decay window', () => { + let t = 1000; + const tracker = createRemoteActivityTracker({ now: () => t, decayMs: 20000, ipcMinMs: 1000 }); + for (let i = 0; i < 50; i++) { + tracker.record('vps', `-srv-a/${String(i).padStart(8, '0')}-1111-2222-3333-444444444444.jsonl`); + t += 10; + } + const filled = tracker.stats(); + assert.equal(filled.seen, 50); + assert.equal(filled.ipc, 50, 'every distinct session took an IPC slot'); + + t += 30000; + tracker.record('vps', '-srv-a/99999999-1111-2222-3333-444444444444.jsonl'); + const pruned = tracker.stats(); + assert.equal(pruned.seen, 1, 'stale sightings must be dropped'); + assert.equal(pruned.ipc, 1, 'stale throttle entries must be dropped too, or the map leaks one entry per session seen'); +}); diff --git a/test/remote-watch.test.js b/test/remote-watch.test.js index 90ac7863..5317ad00 100644 --- a/test/remote-watch.test.js +++ b/test/remote-watch.test.js @@ -128,6 +128,54 @@ test('a burst of same-kind events collapses to far fewer callbacks than events', assert.ok(events.every(e => e.kind === 'project')); }); +test('onActivity fires once per project event, uncoalesced — not collapsed like onEvent', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const events = []; + const activity = []; + + watcher.start('vps', (alias, kind) => events.push({ alias, kind }), (alias, rel) => activity.push({ alias, rel })); + const { child } = spawn.calls[0]; + const rel = `${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl`; + for (let i = 0; i < 19; i++) child.stdout.push(`P|${rel}\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(activity.length, 19, 'onActivity must fire once per raw line, unlike the coalesced onEvent'); + assert.ok(events.length <= 2, 'onEvent must still coalesce the same burst'); + assert.ok(activity.every(a => a.alias === 'vps' && a.rel === '-srv-a/session.jsonl')); +}); + +test('a session-kind event never reaches onActivity', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + const activity = []; + + watcher.start('vps', () => {}, (alias, rel) => activity.push({ alias, rel })); + const { child } = spawn.calls[0]; + child.stdout.push(`S|${REMOTE_SESSIONS_REL}/1234.json\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(activity, [], 'a session descriptor event must not be mistaken for transcript activity'); +}); + +test('start() works with no onActivity supplied — the callback is optional', async () => { + const spawn = spawnRecorder(); + const timers = fakeTimers(); + const watcher = createRemoteWatcher({ spawn, log: silentLog, timers }); + + watcher.start('vps', () => {}); + const { child } = spawn.calls[0]; + child.stdout.push(`P|${REMOTE_PROJECTS_REL}/-srv-a/session.jsonl\n`); + child.stdout.push(null); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(watcher.isRunning('vps'), true, 'a project event with no onActivity wired must not throw'); +}); + test('project and session events are distinguishable in the callback', async () => { const spawn = spawnRecorder(); const timers = fakeTimers();