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
12 changes: 12 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const rendererCrossFileGlobals = {
setActivity: 'readonly',
trackActivity: 'readonly',
applyActivityClasses: 'readonly',
sessionItemEl: 'readonly',
seedRemoteActivity: 'readonly',
rekeyActivityState: 'readonly',
reconcileBusyState: 'readonly',
currentActivitySeq: 'readonly',
Expand Down
2 changes: 1 addition & 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 Down Expand Up @@ -485,7 +485,7 @@
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) {
Expand Down Expand Up @@ -2129,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
36 changes: 22 additions & 14 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
18 changes: 4 additions & 14 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '_');
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 0 additions & 16 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion remote-activity.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
26 changes: 22 additions & 4 deletions test/dom-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
132 changes: 113 additions & 19 deletions test/dom-sidebar-remote-activity-pip.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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({
Expand All @@ -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(); }
});
Loading
Loading