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 => `