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
13 changes: 13 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ and arms a 20 s decay timer (one per session, reset on each event) that calls
busy without waiting for the next event; the visual is the shared `.cli-busy` braille
spinner, not a separate indicator.

The decay call passes `setActivity(sessionId, false, 'remote-decay', { armReady: false })`,
not the bare two-argument form local PTY callers use. 20 s of transcript silence means
"stopped writing", not "the response is ready" — a remote adapter has no PTY to ask
whether a turn actually ended, so a long tool call or a parent delegating to subagents
(its own transcript silent while children write theirs) would otherwise light every
unviewed remote row as `.response-ready` on a plain inference. `armReady: false` clears
`.cli-busy` and `sessionBusyState` through the normal path but skips adding the session to
`responseReadySessions`, so a remote row falls idle without ever claiming "Claude finished,
you haven't looked." Separately, `app.js`'s `updateRunningIndicators` PTY-set purge skips
rows carrying `dataset.remoteAlias` (F7) — a remote row's busy state is owned by this decay
timer, not by local PTY presence, so it must not be cleared just because some unrelated
local PTY started or stopped.

### Remote hosts file-level rescan (issue #216, first half)

**The unit of rescan used to be the folder, not the file.** `syncMirror`
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const rendererCrossFileGlobals = {
reconcileBusyState: 'readonly',
currentActivitySeq: 'readonly',
forgetActivitySeq: 'readonly',
purgeActivityFor: 'readonly',
pruneRemoteActivityTimers: 'readonly',

// Third-party renderer libs loaded as <script>
Expand Down
15 changes: 8 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -809,13 +809,14 @@ function updateRunningIndicators() {
const id = item.dataset.sessionId;
const running = activePtyIds.has(id);
item.classList.toggle('has-running-pty', running);
if (!running) {
if (window.ATRACE) window.atrace('store.purge', id, { reason: 'pty-gone', busy: sessionBusyState.get(id) ?? null, ready: responseReadySessions.has(id), attention: attentionSessions.has(id), fn: 'updateRunningIndicators' });
item.classList.remove('needs-attention', 'response-ready', 'cli-busy', 'has-busy-agents');
attentionSessions.delete(id);
responseReadySessions.delete(id);
sessionBusyState.delete(id);
forgetActivitySeq(id);
// A remote row's busy state is owned by the remote adapter (the watch
// channel), not by local PTY presence — it never enters activePtyIds,
// so purging it here on every unrelated local PTY start/stop would wipe
// its spinner. See .ai/contexts/session-cache.md ("Remote hosts — busy
// spinner").
if (!running && !item.dataset.remoteAlias) {
item.classList.remove('has-busy-agents');
purgeActivityFor(id, 'pty-gone');
// A stopped PTY can never emit subagent-completed (stop-session kills
// the process; detectSubagentTransitions skips exited sessions), so
// drop the live-subagent state now instead of waiting for the TTL.
Expand Down
8 changes: 7 additions & 1 deletion public/remote-activity-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ function clearRemoteActivityTimer(sessionId) {
function armRemoteDecayTimer(sessionId, ms) {
remoteActivityDecayTimers.set(sessionId, setTimeout(() => {
remoteActivityDecayTimers.delete(sessionId);
setActivity(sessionId, false, 'remote-decay');
// 20s of transcript silence means "stopped writing", not "response
// ready" — a remote adapter has no PTY to confirm the turn actually
// ended (long tool call, parent delegating to subagents). Clear busy
// without arming the unread marker. Covers both onRemoteActivityEvent's
// decay and seedRemoteActivity's seed-decay — both arm through this
// function. See .ai/contexts/session-cache.md ("Remote hosts — busy spinner").
setActivity(sessionId, false, 'remote-decay', { armReady: false });
}, ms));
}

Expand Down
28 changes: 26 additions & 2 deletions public/session-activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,32 @@ function applyActivityClasses(sessionId) {
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': item.classList.contains('cli-busy'), fn: 'applyActivityClasses' });
}

// Drop all busy/unread/attention state for a session outside the normal
// active/idle transition — e.g. its PTY just stopped (app.js's
// updateRunningIndicators, via: 'pty-gone'). Sole writer of the three
// collections besides setActivity/rekeyActivityState, so a caller never
// deletes from them directly. Does not touch has-busy-agents/subagent state
// (sidebar.js's clearActiveSubagentsFor) or has-running-pty — those are
// owned elsewhere.
function purgeActivityFor(sessionId, via) {
if (window.ATRACE) window.atrace('store.purge', sessionId, { reason: via, busy: sessionBusyState.get(sessionId) ?? null, ready: responseReadySessions.has(sessionId), attention: attentionSessions.has(sessionId), fn: 'purgeActivityFor' });
attentionSessions.delete(sessionId);
responseReadySessions.delete(sessionId);
sessionBusyState.delete(sessionId);
forgetActivitySeq(sessionId);
const item = sessionItemEl(sessionId);
if (item) item.classList.remove('needs-attention');
applyActivityClasses(sessionId);
}

// Central activity dispatcher. `via` is trace-only — see docs/activity-trace.md.
function setActivity(sessionId, active, via) {
// `opts.armReady` (default true) gates whether going idle may arm
// response-ready; it is an explicit opt-out, never derived from `via`. Pass
// `{ armReady: false }` for a source that can only infer "stopped writing"
// from silence (no PTY to ask "is a response actually ready?") — see
// .ai/contexts/session-cache.md ("Remote hosts — busy spinner").
function setActivity(sessionId, active, via, opts) {
const armReady = !(opts && opts.armReady === false);
if (active) {
if (window.ATRACE && responseReadySessions.has(sessionId)) window.atrace('store.mutate', sessionId, { map: 'responseReadySessions', op: 'delete', from: true, to: false, fn: 'setActivity', via });
responseReadySessions.delete(sessionId);
Expand All @@ -50,7 +74,7 @@ function setActivity(sessionId, active, via) {
activitySeqBySession.set(sessionId, activitySeq);
if (window.ATRACE) window.atrace('store.mutate', sessionId, { map: 'sessionBusyState', op: 'set', from: wasActive, to: active, actSeq: activitySeq, fn: 'setActivity', via });

if (wasActive && !active && sessionId !== activeSessionId) {
if (wasActive && !active && sessionId !== activeSessionId && armReady) {
if (window.ATRACE) window.atrace('store.mutate', sessionId, { map: 'responseReadySessions', op: 'add', from: false, to: true, fn: 'setActivity', via });
responseReadySessions.add(sessionId);
}
Expand Down
3 changes: 3 additions & 0 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,9 @@ function buildSessionItem(session) {
if (parentHasActiveSubagent(session.sessionId)) item.classList.add('has-busy-agents');
if (window.ATRACE && item.className !== 'session-item js-stateful') window.atrace('class.render', session.sessionId, { el: item.id, cls: item.className, fn: 'buildSessionItem' });
item.dataset.sessionId = session.sessionId;
// Read by app.js's updateRunningIndicators — a remote row's busy state is
// owned by the remote adapter, not local PTY presence (F7).
if (session.remoteAlias) item.dataset.remoteAlias = session.remoteAlias;

const modified = new Date(session.modified);
const timeStr = formatDate(modified);
Expand Down
3 changes: 3 additions & 0 deletions test/dom-sidebar-remote-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ test('a remote session row carries a badge naming its host', () => {
const badge = item.querySelector('.remote-badge');
assert.ok(badge, 'a remote row must be visibly distinguishable from a local one');
assert.equal(badge.textContent, 'planificator');
assert.equal(item.dataset.remoteAlias, 'planificator',
'app.js\'s updateRunningIndicators reads dataset.remoteAlias to exempt the row from the PTY-set purge (F7)');
} finally { ctx.destroy(); }
});

Expand Down Expand Up @@ -114,6 +116,7 @@ test('a local session is unaffected: no badge, and the click still opens it', ()

const item = ctx.document.getElementById('si-local-1');
assert.equal(item.querySelector('.remote-badge'), null);
assert.equal(item.dataset.remoteAlias, undefined, 'a local row must not carry dataset.remoteAlias');

const opened = [];
const viewed = [];
Expand Down
12 changes: 9 additions & 3 deletions test/remote-activity-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,22 @@ test('.cli-busy clears once the decay timer fires, and not before', () => {
t.destroy();
});

test('decay routes through setActivity: a non-selected remote session lands in responseReadySessions, exactly like a local one', () => {
test('decay routes through setActivity with armReady:false: a non-selected remote session clears busy WITHOUT landing in responseReadySessions', () => {
// F3: 20s of transcript silence means "stopped writing", not "the response
// is ready" — a remote adapter has no PTY to confirm a turn actually ended
// (a long tool call, or a parent delegating to subagents whose own
// transcript stays silent). Decay must not claim the turn is done.
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');
assert.equal(t.sessionBusyState.get('s1'), false, 'busy is cleared');
assert.ok(!t.responseReadySessions.has('s1'), 'decay must NOT arm the unread/response-ready marker');
assert.ok(!t.item('s1').classList.contains('cli-busy'), 'the spinner is off');
assert.ok(!t.item('s1').classList.contains('response-ready'), 'the row must not claim a finished response it never measured');
t.destroy();
});

Expand Down
86 changes: 86 additions & 0 deletions test/running-indicators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const { setupSidebarDom } = require('./dom-setup');

// ---------------------------------------------------------------------------
// Minimal DOM setup
Expand Down Expand Up @@ -339,6 +340,91 @@ test('updateRunningIndicators: empty pty-set — all sessions marked stopped', (
window.close();
});

// ---------------------------------------------------------------------------
// F7 — remote rows are exempt from the PTY-set purge.
//
// Unlike the replica tests above (hand-rolled Maps/Sets), these drive the
// REAL session-activity.js/sidebar.js/remote-activity-ui.js via dom-setup.js
// (same technique as test/dom-sidebar-remote-session.test.js). The gating
// loop itself is still a hand-mirror of updateRunningIndicators — app.js
// cannot be eval'd in jsdom (see file header) — but the state it mutates
// (sessionBusyState/responseReadySessions/attentionSessions, and the purge
// itself) is the real purgeActivityFor from session-activity.js, not a
// replica. Keep this mirror's remote-skip condition in sync with app.js's
// `if (!running && !item.dataset.remoteAlias)`.
// ---------------------------------------------------------------------------

function runIndicatorPass(doc, activePtyIds) {
doc.querySelectorAll('.session-item').forEach(item => {
if (item.dataset.subagent) return;
const id = item.dataset.sessionId;
const running = activePtyIds.has(id);
item.classList.toggle('has-running-pty', running);
if (!running && !item.dataset.remoteAlias) {
item.classList.remove('has-busy-agents');
doc.defaultView.purgeActivityFor(id, 'pty-gone');
}
});
}

test('F7: a remote row busy via onRemoteActivityEvent stays .cli-busy across an unrelated local pty-set change', () => {
const ctx = setupSidebarDom();
try {
const sidebarContent = ctx.document.getElementById('sidebar-content');
const localItem = ctx.document.createElement('div');
localItem.className = 'session-item';
localItem.dataset.sessionId = 'local-1';
localItem.innerHTML = '<span class="session-status-dot"></span>';
const remoteItem = ctx.document.createElement('div');
remoteItem.className = 'session-item';
remoteItem.dataset.sessionId = 'remote-1';
remoteItem.dataset.remoteAlias = 'vps';
remoteItem.innerHTML = '<span class="session-status-dot"></span>';
sidebarContent.append(localItem, remoteItem);

// Real onRemoteActivityEvent (remote-activity-ui.js), as the watch
// channel's IPC event would drive it — routes through the real setActivity.
ctx.window.onRemoteActivityEvent({ sessionId: 'remote-1' });
assert.ok(remoteItem.classList.contains('cli-busy'), 'precondition: remote row busy via the real dispatcher');
assert.equal(ctx.sessionBusyState.get('remote-1'), true);

runIndicatorPass(ctx.document, new Set(['local-1']));
assert.ok(remoteItem.classList.contains('cli-busy'), 'remote row still busy after a pass with local-1 running');

// local-1 stops — an unrelated local pty-set change.
runIndicatorPass(ctx.document, new Set());

assert.ok(remoteItem.classList.contains('cli-busy'), 'remote row must NOT be purged by an unrelated local pty-set change');
assert.equal(ctx.sessionBusyState.get('remote-1'), true, 'sessionBusyState for the remote row is untouched');
assert.ok(!localItem.classList.contains('has-running-pty'), 'the local row is still correctly marked not running');
} finally {
ctx.destroy();
}
});

test('F7: a local non-running row is still purged through purgeActivityFor', () => {
const ctx = setupSidebarDom();
try {
const sidebarContent = ctx.document.getElementById('sidebar-content');
const localItem = ctx.document.createElement('div');
localItem.className = 'session-item';
localItem.dataset.sessionId = 'local-1';
localItem.innerHTML = '<span class="session-status-dot"></span>';
sidebarContent.append(localItem);

ctx.setActivity('local-1', true, 'onCliBusyState');
runIndicatorPass(ctx.document, new Set(['local-1']));
assert.ok(localItem.classList.contains('cli-busy'), 'precondition: local row busy while its pty runs');

runIndicatorPass(ctx.document, new Set()); // the pty stops

assert.ok(!localItem.classList.contains('cli-busy'), 'a stopped local row must still be purged');
assert.equal(ctx.sessionBusyState.has('local-1'), false, 'sessionBusyState entry dropped for the stopped local row');
} finally {
ctx.destroy();
}
});

// ---------------------------------------------------------------------------
// Source-level pin for the REAL public/app.js (not the replica above).
//
Expand Down
Loading
Loading