Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ const rendererCrossFileGlobals = {
reconcileBusyState: 'readonly',
currentActivitySeq: 'readonly',
forgetActivitySeq: 'readonly',
pruneRemoteActivityTimers: 'readonly',

// Third-party renderer libs loaded as <script>
morphdom: 'readonly',
Expand Down
16 changes: 15 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand Down Expand Up @@ -453,8 +453,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 457 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand All @@ -464,6 +464,7 @@
const { createSshTransport } = require('./remote-transport');
const { createRemoteIndexer } = require('./remote-index');
const { createRemoteWatcher } = require('./remote-watch');
const { createRemoteActivityTracker } = require('./remote-activity');

const remoteTransport = createSshTransport({ log });
const remoteIndexer = createRemoteIndexer({
Expand All @@ -483,14 +484,26 @@
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")
const remoteActivityTracker = createRemoteActivityTracker({});

function onRemoteWatchActivity(alias, rel) {
const result = remoteActivityTracker.record(alias, rel);
if (!result) return;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('remote-activity', result);
}
}

function syncRemoteWatchers() {
const declared = enabledHosts((getSetting('global') || {}).remoteHosts);
const wanted = new Set(declared.map(h => h.alias));
for (const alias of watchedAliases) {
if (!wanted.has(alias)) remoteWatcher.stop(alias);
}
for (const host of declared) {
if (!remoteWatcher.isRunning(host.alias)) remoteWatcher.start(host.alias, onRemoteWatchEvent);
if (!remoteWatcher.isRunning(host.alias)) remoteWatcher.start(host.alias, onRemoteWatchEvent, onRemoteWatchActivity);
}
watchedAliases = wanted;
}
Expand Down Expand Up @@ -525,6 +538,7 @@
session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor));
session.remoteStatus = descriptor ? (descriptor.status || null) : null;
session.remoteStatusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null;
session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId);
}
}
return projects;
Expand Down Expand Up @@ -2115,7 +2129,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2132 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
3 changes: 3 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ contextBridge.exposeInMainWorld('api', {
onProjectsChanged: (callback) => {
ipcRenderer.on('projects-changed', () => callback());
},
onRemoteActivity: (callback) => {
ipcRenderer.on('remote-activity', (_event, payload) => callback(payload));
},
onStatusUpdate: (callback) => {
ipcRenderer.on('status-update', (_event, text, type) => callback(text, type));
},
Expand Down
1 change: 1 addition & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ function refreshSidebar({ resort = false } = {}) {
}

renderProjects(projects, resort);
pruneRemoteActivityTimers();
}

// --- Archive toggle ---
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
<script src="jsonl-viewer.js"></script>
<script src="dialogs.js"></script>
<script src="sidebar.js"></script>
<script src="remote-activity-ui.js"></script>
<script src="memory-workfiles-view.js"></script>
<!-- restore-plan.js decides working-set restore vs. defer-and-retry; consumed by app.js only. -->
<script src="restore-plan.js"></script>
Expand Down
38 changes: 38 additions & 0 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 14 additions & 0 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '_');
}
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
61 changes: 61 additions & 0 deletions remote-activity.js
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 4 additions & 2 deletions remote-watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 },
};
Expand All @@ -149,14 +150,15 @@ 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;
s.stopped = false;
s.unwatchable = false;
s.failures = 0;
s.onEvent = onEvent;
s.onActivity = typeof onActivity === 'function' ? onActivity : null;
spawnChild(s);
}

Expand Down
82 changes: 82 additions & 0 deletions test/dom-sidebar-remote-activity-pip.test.js
Original file line number Diff line number Diff line change
@@ -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(); }
});
Loading
Loading