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
2 changes: 1 addition & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ session object exists.
Three things make that safe:

- **The response-ready lock only blocks idle.** `setActivity(id, true)` always writes, and drops the session from `responseReadySessions` — a session that resumed generating has no unread answer left to announce. `setActivity(id, false)` on a response-ready session is still ignored, so an unread marker survives duplicate idle signals. Before that split, a session that finished a turn off-screen and restarted without a click (cron, trigger-watcher, resume) had *every* subsequent busy event swallowed.
- **`cli-busy` and `response-ready` are mutually exclusive.** `applyActivityClasses()` is the only writer of either class. The cascade would in fact favour the spinner anyway (`.session-item.cli-busy:not(.needs-attention) .session-status-dot` carries `!important` and one more class than the response-ready rule that follows it in `style.css`), but the state, not the cascade, is what decides.
- **`cli-busy` and `response-ready` are mutually exclusive.** `applyStateClasses()` (`public/session-activity-dom.js`) is the only writer of either class, and the exclusivity itself is the domain's own invariant (`public/session-state.js`'s `apply()`) — see `.ai/contexts/session-state.md` ("The local-pty adapter"). The state, not the cascade, is what decides.
- **A poll reply cannot overwrite a fresher event.** `setActivity` bumps a monotonic counter per session; the poll snapshots it via `currentActivitySeq()` *before* the IPC round-trip and `reconcileBusyState` skips any session that moved in between.

### The OSC 0 title is the primary busy channel
Expand Down
318 changes: 253 additions & 65 deletions .ai/contexts/session-state.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/activity-trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ no `sent`.
| `store.purge` | State dropped because the PTY is gone | `reason`, `busy`, `ready`, `attention` |
| `store.rekey` | Activity state carried across a fork | `from`, `busy`, `ready`, `attention` |
| `subagents.prune` | The 60 s TTL sweep ran | `parents`, `agents` |
| `class.apply` | `cli-busy` / `response-ready` written | `el`, both class states |
| `class.toggle` | `needs-attention` / `has-running-pty` written | `el`, `cls`, `on` |
| `class.apply` | `needs-attention` / `cli-busy` / `response-ready` / `has-busy-agents` / `is-alive` written (`applyStateClasses`, all three session kinds) | `el`, `needs-attention`, `cli-busy`, `response-ready`, `has-busy-agents`, `is-alive`, `kind` |
| `class.toggle` | `has-running-pty` written | `el`, `cls`, `on` |
| `class.subagent` | Subagent `running` / `has-running-child` / `has-busy-agents` written | `el` ids, `running` |
| `class.render` | A full sidebar render reconstructed an item's classes from the stores | `el`, `cls` |
| `poll.recv` | The poll reply reaches the renderer | `sinceSeq`, `entries` |
Expand Down
7 changes: 5 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ const rendererCrossFileGlobals = {
saveExpandedSlugs: 'readonly',
setActivity: 'readonly',
trackActivity: 'readonly',
applyActivityClasses: 'readonly',
setAttention: 'readonly',
syncLocalPtyAgentsBusy: 'readonly',
sessionItemEl: 'readonly',
seedRemoteActivity: 'readonly',
rekeyActivityState: 'readonly',
Expand All @@ -150,6 +151,9 @@ const rendererCrossFileGlobals = {
forgetActivitySeq: 'readonly',
purgeActivityFor: 'readonly',
pruneRemoteActivityTimers: 'readonly',
// public/session-activity.js's persisted per-session state — see .ai/contexts/session-state.md ("The local-pty adapter")
localPtyState: 'readonly',
localPtyStates: 'readonly',
// Changes panel no-polling refresh hook (issue #251, public/file-panel.js)
onSessionIdle: 'readonly',
// public/session-state.js (pure domain, see .ai/contexts/session-state.md)
Expand All @@ -158,7 +162,6 @@ const rendererCrossFileGlobals = {
// public/session-activity-dom.js — the only file allowed to write
// .cli-busy/.needs-attention/.response-ready/.has-busy-agents, and the only
// file allowed to write the .session-icon slot (issue #246, step 3b).
applyActivityClassesToElement: 'readonly',
applyStateClasses: 'readonly',
setNeedsAttention: 'readonly',
setResponseReady: 'readonly',
Expand Down
13 changes: 4 additions & 9 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,7 @@ function trackActivity(sessionId, data) {

function clearNotifications(sessionId) {
clearUnread(sessionId, 'clearNotifications');
if (window.ATRACE && attentionSessions.has(sessionId)) window.atrace('store.mutate', sessionId, { map: 'attentionSessions', op: 'delete', from: true, to: false, fn: 'clearNotifications' });
attentionSessions.delete(sessionId);
setNeedsAttention(sessionItemEl(sessionId), false);
setAttention(sessionId, false, 'clearNotifications');
}
// Terminal themes, utils (cleanDisplayName, formatDate, escapeHtml, shellEscape)
// are defined in terminal-themes.js and utils.js (loaded before app.js).
Expand Down Expand Up @@ -460,11 +458,7 @@ window.api.onTerminalNotification((sessionId, message) => {
// 3. "Claude needs your permission to use {tool}" → permission, needs your
// 4. "Claude Code wants to enter plan mode" → wants to enter
if (/attention|approval|permission|needs your|wants to enter/i.test(message) && sessionId !== activeSessionId) {
if (window.ATRACE) window.atrace('store.mutate', sessionId, { map: 'attentionSessions', op: 'add', from: attentionSessions.has(sessionId), to: true, fn: 'onTerminalNotification' });
attentionSessions.add(sessionId);
const item = sessionItemEl(sessionId);
if (window.ATRACE) window.atrace('class.toggle', sessionId, { el: item ? item.id : null, cls: 'needs-attention', on: true, fn: 'onTerminalNotification' });
setNeedsAttention(item, true);
setAttention(sessionId, true, 'onTerminalNotification');
} else if (/waiting for your input/i.test(message)) {
// "Claude is waiting for your input" — delayed idle notification, mark response-ready
setActivity(sessionId, false, 'onTerminalNotification');
Expand Down Expand Up @@ -836,11 +830,12 @@ function updateRunningIndicators() {
// remote rows are owned by the remote adapter — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner")
if (!running && !item.dataset.remoteAlias) {
setHasBusyAgents(item, false);
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.
clearActiveSubagentsFor(id);
// Runs after clearActiveSubagentsFor — see .ai/contexts/session-state.md ("The local-pty adapter")
purgeActivityFor(id, 'pty-gone');
}
if (item.dataset.remoteAlias) setRemoteAttached(id, running);
// local-pty takes over a row the user just opened — see .ai/contexts/session-state.md
Expand Down
2 changes: 2 additions & 0 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ function setRemoteAttached(sessionId, attached) {
remoteSeedFloors.set(sessionId, Date.now());
state.apply({ type: 'busy', active: false, armReady: false });
setActivity(sessionId, false, 'remote-attach-handoff', { armReady: false });
// Drops the shadow local-pty entry setActivity() just touched above — see .ai/contexts/session-state.md ("The local-pty adapter")
purgeActivityFor(sessionId, 'remote-detach');
}
projectRemoteState(sessionId);
}
Expand Down
49 changes: 9 additions & 40 deletions public/session-activity-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,24 @@ function isSessionAlive(sessionId) {
return !!(el && el.classList.contains('is-alive'));
}

// local-pty only for now — see session-state.md "migration status".
function computeBusyReadyClasses(sessionId) {
const busy = sessionBusyState.get(sessionId) === true;
const ready = !busy && responseReadySessions.has(sessionId);
const state = createSessionState('local-pty');
if (busy) state.apply({ type: 'busy', active: true });
else if (ready) state.apply({ type: 'busy', active: false, armReady: true });
return renderSessionIcon(state.snapshot()).classes;
}

// The only writer of .cli-busy and .response-ready — they are mutually exclusive.
function applyActivityClassesToElement(item, sessionId) {
if (!item) return;
const classes = computeBusyReadyClasses(sessionId);
const ready = classes.includes('response-ready');
const busy = classes.includes('cli-busy');
setResponseReady(item, ready);
setCliBusy(item, busy);
paintSessionIcon(item.querySelector('.session-icon'), sessionId);
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, fn: 'applyActivityClasses' });
}

function applyActivityClasses(sessionId) {
applyActivityClassesToElement(sessionItemEl(sessionId), sessionId);
}

// Snapshot-driven projection for adapter-owned state; has-busy-agents read off the snapshot — see .ai/contexts/session-state.md
// Snapshot-driven projection — the one path for all three kinds — see .ai/contexts/session-state.md ("The local-pty adapter")
function applyStateClasses(sessionId, snapshot) {
const item = sessionItemEl(sessionId);
if (!item) return;
const icon = renderSessionIcon(snapshot);
const ready = icon.classes.includes('response-ready');
const busy = icon.classes.includes('cli-busy');
// Row classes read snapshot fields directly, not icon.classes — see .ai/contexts/session-state.md ("The local-pty adapter")
const attention = !!(snapshot && snapshot.attention);
const ready = !!(snapshot && snapshot.responseReady);
const busy = !!(snapshot && snapshot.busy);
const agentsBusy = !!(snapshot && snapshot.agentsBusy);
const alive = !!(snapshot && snapshot.liveness === 'alive');
setNeedsAttention(item, attention);
setResponseReady(item, ready);
setCliBusy(item, busy);
setHasBusyAgents(item, agentsBusy);
setIsAlive(item, alive);
writeIconSlot(item.querySelector('.session-icon'), icon);
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, 'has-busy-agents': agentsBusy, 'is-alive': alive, fn: 'applyStateClasses', kind: snapshot && snapshot.kind });
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'needs-attention': attention, 'response-ready': ready, 'cli-busy': busy, 'has-busy-agents': agentsBusy, 'is-alive': alive, fn: 'applyStateClasses', kind: snapshot && snapshot.kind });
}

// One icon slot per row, written here and nowhere else — see .ai/contexts/session-state.md
Expand All @@ -85,17 +62,9 @@ function writeIconSlot(el, icon) {
el.dataset.glyph = icon.glyph || '';
}

// local-pty snapshot for the icon slot (full priority ladder, unlike computeBusyReadyClasses) — see .ai/contexts/session-state.md
// Thin wrapper over the local-pty adapter's own persisted state — see .ai/contexts/session-state.md ("The local-pty adapter")
function snapshotForLocal(sessionId, session) {
const state = createSessionState('local-pty');
const busy = sessionBusyState.get(sessionId) === true;
const ready = !busy && responseReadySessions.has(sessionId);
if (busy) state.apply({ type: 'busy', active: true });
else if (ready) state.apply({ type: 'busy', active: false, armReady: true });
if (attentionSessions.has(sessionId)) state.apply({ type: 'attention', active: true });
if (typeof parentHasActiveSubagent === 'function' && parentHasActiveSubagent(sessionId)) {
state.apply({ type: 'subagentSpawned' });
}
const state = localPtyState(sessionId);
const sess = session || (typeof sessionMap !== 'undefined' && sessionMap.get(sessionId));
if (sess && sess.status !== undefined) {
// session.status present is itself the liveness signal — see .ai/contexts/cli-session-state.md
Expand Down
Loading
Loading