From 48b6f2357d7a832c553a64a82fe6603f6b3f2e8e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:03:04 +0200 Subject: [PATCH 1/4] (remote): light the attention state when a remote session waits on a dialog Under tmux on a host the CLI emits no OSC 9, so an unattached remote session waiting on a permission prompt or a question stayed dark. The descriptor already carries status "waiting" (a blocking dialog is open); the remote adapter now turns it into attention, released when the status leaves waiting, the descriptor is gone or the session is dead. waitingFor is kept, validated, and shown in the status line. Closes #394 --- .ai/contexts/cli-session-state.md | 15 +++- .ai/contexts/session-state.md | 30 ++++++++ CHANGELOG.md | 3 + docs/remote-hosts.md | 7 ++ main.js | 2 + public/remote-activity-ui.js | 2 +- public/session-state.js | 18 +++++ public/sidebar.js | 3 +- remote-index.js | 21 +++++- ...ate-remote-attachable-local-status.test.js | 23 ++++++ test/dom-sidebar-local-status.test.js | 14 ++++ test/remote-index.test.js | 40 ++++++++++ test/remote-session-adapter.test.js | 41 ++++++++++ test/session-state.test.js | 74 +++++++++++++++++++ 14 files changed, 285 insertions(+), 8 deletions(-) diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 117ce655..b75408e1 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -59,10 +59,17 @@ Facts established by measurement, not by documentation: - Sampling 295 times at 2 s over 10 min with 2–3 subagents writing, `status` stayed `busy` throughout, with no false dip. The parent is `busy` while any delegated agent runs (`delegatedActive` in the CLI's own status computation). -- **Unverified reservation**: no permission dialog occurred during that - measurement, so the `waiting` branch was never observed empirically. We treat - `waiting` as "not idle" on the strength of the name alone. If that reading is - wrong, the only consequence is a missed early rescan — the tick still fires. +- **`waiting` (read from the CLI bundle, 2.1.286; still not observed live)**: + the status computation returns `waiting` whenever a blocking dialog is open, + ahead of `busy`; the prompt at rest is `idle`, never `waiting`. A + `waitingFor` string is written beside it (`permission prompt`, + `input needed`, `dialog open`, `goal proposal`, `worker request`, + `sandbox request`), and `statusUpdatedAt` is rewritten on each status write. + No permission dialog occurred during the 295-sample measurement above, so no + live descriptor was captured during a dialog. A remote row lights the + attention state from it (see session-state.md, "Descriptor-owned attention"); + the remote index keeps `waitingFor` only as a trimmed string of at most 64 + characters without control characters, and drops anything else. The `busy` glyph in the terminal title was considered instead and rejected: it conflates idle, waiting and shell. The state file distinguishes them, which is diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 2b7594cc..ba6b8933 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -229,6 +229,36 @@ per remote session id in `remoteSessionStates` (a `Map`, pruned in `projectLocalPtyState`, see "The local-pty adapter" below), just fed from the remote-ssh adapter's own snapshot instead. +### Descriptor-owned attention (issue #394) + +A CLI that has a blocking dialog open (permission prompt, question, +elicitation) writes `status: "waiting"` to its descriptor — see +cli-session-state.md. OSC 9 does not reach Switchboard from a tmux pane on a +host, so the descriptor is the only signal an unattached remote row has. + +`applyRemoteDescriptor` passes `attention: true` on its `descriptorStatus` +event. Only the remote adapter does: local callers send the same event without +it and never get attention from the descriptor. The reducer's rule: + +- `waiting` with `attention: true` and liveness not dead sets attention, + clears busy / waitingForInput / responseReady like an `attention` event, and + records that the descriptor owns it. +- Any other status (`busy`, `idle`, `shell`, none), a `liveness: dead` event, + or an `attention` event releases it — but only when the descriptor owns it. An + attention raised by an explicit `attention` event is never cleared by a + descriptor, and that event takes ownership over a descriptor-set one. +- Busy edges and transcript touches never clear it (attention is orthogonal to + busy and outranks it), so a busy decay cannot erase an open dialog's state. + The status leaving `waiting` is the one thing that clears it. +- Descriptor absence is a null status on the session object, which releases it; + an unattached row whose descriptor disappears loses the attention at the next + refresh. `applyRemoteStopped` also clears it. + +Attached rows are untouched: `projectRemoteState` still refuses to paint them, +and the local-pty path owns their attention. The sidebar's status line appends +`waitingFor` while the status is `waiting` (`waiting · permission prompt · 3m +ago`). Latency is the refresh cycle, not the dialog. + ### A parent's busy decay shortens while a subagent is running (issue #284) A Task-tool invocation typically appends to the parent's own top-level diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..82e62bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### New +- A remote session that is not open in a tab and waits on a dialog on its host, such as a permission prompt or a question, shows the orange attention state, and its status line says what it waits for. It appears and clears with the next refresh of the host. (#394) + ## v0.0.86 — 2026-10-01 ### New diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 909e4193..1aacf291 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -129,6 +129,13 @@ written, until 20 seconds of silence (3 seconds while a subagent is busy), and never "response ready". A session that has a live descriptor but no transcript yet (before its first prompt) is listed under its directory's name. +A session that is not open in a tab and is waiting on a dialog on the host +(a permission prompt or a question) shows the orange attention state, and its +status line says what it waits for. The state comes from the session's +descriptor, so it appears with the next refresh (about 15 seconds with the host +watch, the pull interval without it) and clears the same way once the dialog is +answered. A session open in a tab keeps the terminal's own signals. + ### Opening a session - A **live session in tmux** — its descriptor names a tmux pane and its pid is a diff --git a/main.js b/main.js index 795ca5e8..541ae15f 100644 --- a/main.js +++ b/main.js @@ -578,6 +578,7 @@ function annotateRemoteAttachable(projects) { session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); session.status = descriptor ? (descriptor.status || null) : null; session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; + session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null; session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId); // listed descriptor = live process (ALIVE filter) — see .ai/contexts/session-state.md session.remoteDescriptorSeen = !!descriptor; @@ -616,6 +617,7 @@ function toSidebarPlaceholderSession(ph) { remoteDescriptorSeen: ph.remoteDescriptorSeen, status: ph.status, statusUpdatedAt: ph.statusUpdatedAt, + waitingFor: ph.waitingFor, placeholder: true, }; } diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js index 998beec1..3f726317 100644 --- a/public/remote-activity-ui.js +++ b/public/remote-activity-ui.js @@ -164,7 +164,7 @@ function applyRemoteDescriptor(session) { const state = remoteState(session.sessionId); if (session.remoteDescriptorSeen) state.apply({ type: 'liveness', value: 'alive' }); if (session.status !== undefined) { - state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt }); + state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt, attention: true }); } projectRemoteState(session.sessionId); } diff --git a/public/session-state.js b/public/session-state.js index cc3ce08b..f690227c 100644 --- a/public/session-state.js +++ b/public/session-state.js @@ -21,6 +21,7 @@ function createSessionState(kind) { let busy = false; let waitingForInput = false; let attention = false; + let attentionFromDescriptor = false; let responseReady = false; let agentsBusy = false; let lastActivityAt = null; @@ -37,6 +38,12 @@ function createSessionState(kind) { responseReady = false; } + function releaseDescriptorAttention() { + if (!attentionFromDescriptor) return; + attention = false; + attentionFromDescriptor = false; + } + function touch(event) { if (event && event.at !== undefined) lastActivityAt = event.at; if (event && event.source !== undefined) lastActivitySource = event.source; @@ -57,6 +64,7 @@ function createSessionState(kind) { touch(event); break; case 'attention': + attentionFromDescriptor = false; if (event.active === false) { attention = false; } else { @@ -70,6 +78,7 @@ function createSessionState(kind) { break; case 'liveness': liveness = event.value === 'alive' || event.value === 'dead' ? event.value : 'unknown'; + if (liveness === 'dead') releaseDescriptorAttention(); break; case 'attached': attached = !!event.value; @@ -79,6 +88,15 @@ function createSessionState(kind) { break; case 'descriptorStatus': liveness = event.status === 'alive' || event.status === 'dead' ? event.status : liveness; + if (event.attention === true) { + if (event.status === 'waiting' && liveness !== 'dead') { + clearExclusive(); + attention = true; + attentionFromDescriptor = true; + } else { + releaseDescriptorAttention(); + } + } touch({ at: event.at, source: 'descriptor' }); break; case 'subagentSpawned': diff --git a/public/sidebar.js b/public/sidebar.js index 3a448323..defe9260 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1443,7 +1443,8 @@ function buildSessionItem(session) { statusEl.className = 'session-status'; if (session.status) { const age = formatStatusAge(session.statusUpdatedAt); - statusEl.textContent = session.status + (age ? ' · ' + age : ''); + const why = session.status === 'waiting' && session.waitingFor ? ' · ' + session.waitingFor : ''; + statusEl.textContent = session.status + why + (age ? ' · ' + age : ''); } metaEl.append(timeEl, shortIdEl, statusEl); diff --git a/remote-index.js b/remote-index.js index 13b83f95..729e9ff9 100644 --- a/remote-index.js +++ b/remote-index.js @@ -32,6 +32,20 @@ function placeholderTitle(cwd) { return parts[parts.length - 1] || cwd; } +const MAX_WAITING_FOR_LENGTH = 64; + +// see .ai/contexts/cli-session-state.md ("waiting") +function sanitizeWaitingFor(value) { + if (typeof value !== 'string') return null; + const text = value.trim(); + if (!text || text.length > MAX_WAITING_FOR_LENGTH) return null; + for (let k = 0; k < text.length; k++) { + const code = text.charCodeAt(k); + if (code < 32 || code === 127) return null; + } + return text; +} + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") function buildPlaceholderSession(alias, descriptor) { const id = (typeof descriptor.sessionId === 'string' && descriptor.sessionId) @@ -45,6 +59,7 @@ function buildPlaceholderSession(alias, descriptor) { remoteDescriptorSeen: true, status: descriptor.status || null, statusUpdatedAt: descriptor.statusUpdatedAt || null, + waitingFor: sanitizeWaitingFor(descriptor.waitingFor), modified: descriptor.statusUpdatedAt || descriptor.startedAt || null, messageCount: 0, summary: placeholderTitle(descriptor.cwd), @@ -179,7 +194,9 @@ function createRemoteIndexer(ctx) { log, }); - remoteSessions.set(host.alias, Array.isArray(result.sessions) ? result.sessions : []); + remoteSessions.set(host.alias, Array.isArray(result.sessions) + ? result.sessions.map(s => (s && 'waitingFor' in s ? { ...s, waitingFor: sanitizeWaitingFor(s.waitingFor) } : s)) + : []); const folderPrefix = host.alias; const toScan = new Set(result.changedFolders); @@ -394,4 +411,4 @@ function createRemoteIndexer(ctx) { }; } -module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle }; +module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle, sanitizeWaitingFor }; diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index b30e949e..3e71dea8 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -111,3 +111,26 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip assert.equal(projects[0].sessions[0].statusUpdatedAt, 999); assert.equal(projects[0].sessions[0].remoteAttachable, true); }); + +test('a remote session carries the descriptor waitingFor, and null when the descriptor has none or is gone', () => { + const annotateRemoteAttachable = makeAnnotate({ + remoteIndexer: { + getRemoteSessions: () => ({ + sessions: [ + { sessionId: 'w', status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt' }, + { sessionId: 'i', status: 'idle', statusUpdatedAt: 1 }, + ], + at: 1, + error: null, + }), + getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }), + }, + }); + const projects = [{ + projectPath: '/srv/proj', + remoteAlias: 'p', + sessions: ['w', 'i', 'gone'].map(sessionId => ({ sessionId, remoteAlias: 'p' })), + }]; + annotateRemoteAttachable(projects); + assert.deepEqual(projects[0].sessions.map(s => s.waitingFor), ['permission prompt', null, null]); +}); diff --git a/test/dom-sidebar-local-status.test.js b/test/dom-sidebar-local-status.test.js index 83c96b20..9ec43ed0 100644 --- a/test/dom-sidebar-local-status.test.js +++ b/test/dom-sidebar-local-status.test.js @@ -136,3 +136,17 @@ test('renderProjects wires a real project fixture with a mix of local and remote assert.ok(el.querySelector('.session-status'), 'the local session row must show its status line'); } finally { ctx.destroy(); } }); + +test('a waiting session names what it waits for in the status line', () => { + const ctx = setupSidebarDom(); + try { + const base = { + summary: 'remote session', modified: '2026-09-09T11:59:00.000Z', starred: false, archived: 0, + messageCount: 1, remoteAlias: 'planificator', status: 'waiting', + }; + const waiting = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'w', waitingFor: 'permission prompt' }); + assert.match(waiting.querySelector('.session-status').textContent, /^waiting · permission prompt/); + const busy = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'b', status: 'busy', waitingFor: 'permission prompt' }); + assert.doesNotMatch(busy.querySelector('.session-status').textContent, /permission/); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index ba6ac7bd..c3a44e7e 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -888,3 +888,43 @@ test('refreshNow({force:true}) ignores backoff for every host and resets it on s assert.equal(recovered.nextAttemptAt, 0); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +test('sanitizeWaitingFor keeps a short plain string and drops everything else', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + assert.equal(sanitizeWaitingFor('permission prompt'), 'permission prompt'); + assert.equal(sanitizeWaitingFor(' input needed '), 'input needed'); + assert.equal(sanitizeWaitingFor('x'.repeat(64)), 'x'.repeat(64)); + assert.equal(sanitizeWaitingFor('x'.repeat(65)), null, 'over the length bound'); + for (const bad of [undefined, null, 5, {}, ['a'], '', ' ', 'a\nb', 'a\u001b[31mb', 'a\u007fb']) { + assert.equal(sanitizeWaitingFor(bad), null, JSON.stringify(bad)); + } +}); + +test('the indexer and the placeholder keep a validated waitingFor beside the status', async () => { + const dataDir = tmp('idx-waiting'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async () => ({ + fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, + changedFolders: new Set(), + sessions: [ + { pid: 1, sessionId: 'a', cwd: '/srv/a', status: 'waiting', waitingFor: 'permission prompt', descriptorOnly: true }, + { pid: 2, sessionId: 'b', cwd: '/srv/b', status: 'waiting', waitingFor: 'x'.repeat(500), descriptorOnly: true }, + ], + }), + }); + await indexer.refreshNow(); + const byId = new Map(indexer.getRemoteSessions('vps').sessions.map(s => [s.sessionId, s])); + assert.equal(byId.get('a').waitingFor, 'permission prompt'); + assert.equal(byId.get('b').waitingFor, null, 'an oversized value is dropped, not truncated'); + const ph = new Map(indexer.getPlaceholderSessions('vps').map(s => [s.sessionId, s])); + assert.equal(ph.get('a').waitingFor, 'permission prompt'); + assert.equal(ph.get('b').waitingFor, null); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); diff --git a/test/remote-session-adapter.test.js b/test/remote-session-adapter.test.js index 6b8100ee..a35169f6 100644 --- a/test/remote-session-adapter.test.js +++ b/test/remote-session-adapter.test.js @@ -398,3 +398,44 @@ test('(6) a rebuild inside the short window re-arms only for what is left of it, assert.equal(t.snapshot('s1').busy, false, 'busy decays at t=3000 — the short window, not the full 20s'); t.destroy(); }); + +const waitingSession = (over) => ({ + sessionId: 's1', remoteAlias: 'planificator', remoteDescriptorSeen: true, + status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt', ...over, +}); + +test('an unattached remote session whose descriptor says waiting shows attention on its row', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + assert.equal(t.snapshot('s1').attention, true); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('the remote attention clears when the descriptor goes busy, idle or vanishes', () => { + for (const next of [{ status: 'busy' }, { status: 'idle' }, { status: null, remoteDescriptorSeen: false }]) { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + t.seedRemoteActivity(waitingSession(next)); + assert.equal(t.snapshot('s1').attention, false, JSON.stringify(next)); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); + } +}); + +test('an attached remote row is not painted attention by the descriptor', () => { + const t = setup(['s1']); + t.setRemoteAttached('s1', true); + t.seedRemoteActivity(waitingSession()); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('a decayed busy edge does not erase a waiting attention', () => { + const t = setup(['s1']); + t.emit({ sessionId: 's1', at: Date.now() }); + t.seedRemoteActivity(waitingSession()); + t.pending()[0].fn(); + assert.equal(t.snapshot('s1').attention, true); + t.destroy(); +}); diff --git a/test/session-state.test.js b/test/session-state.test.js index f0219bc6..0aa5358c 100644 --- a/test/session-state.test.js +++ b/test/session-state.test.js @@ -256,3 +256,77 @@ test('renderSessionIcon: slot class, glyph and title move together with the rung assert.deepEqual(agentsBusy, { classes: ['has-busy-agents'], slotClasses: ['session-icon--agents-busy'], glyph: '◆', title: 'Subagents running' }); assert.notDeepEqual(busy, agentsBusy); }); + +// --------------------------------------------------------------------------- +// descriptor-owned attention (issue #394) — see .ai/contexts/session-state.md +// --------------------------------------------------------------------------- + +test('descriptorStatus waiting with attention:true lights attention and clears busy and unread', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'descriptorStatus', status: 'waiting', at: 1, attention: true }); + const snap = s.snapshot(); + assert.equal(snap.attention, true); + assert.equal(snap.busy, false); + assert.equal(renderSessionIcon(snap).slotClasses[0], 'session-icon--attention'); +}); + +test('descriptorStatus waiting without attention:true never lights attention (local callers)', () => { + const s = createSessionState('local-pty'); + s.apply({ type: 'descriptorStatus', status: 'waiting', at: 1 }); + assert.equal(s.snapshot().attention, false); +}); + +test('descriptor-owned attention clears when the status leaves waiting, or is null', () => { + for (const next of ['busy', 'idle', null]) { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'descriptorStatus', status: next, attention: true }); + assert.equal(s.snapshot().attention, false, `status ${next} clears attention`); + } +}); + +test('descriptor-owned attention clears when liveness turns dead', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'liveness', value: 'dead' }); + assert.equal(s.snapshot().attention, false); +}); + +test('descriptor-owned attention survives busy edges and transcript touches', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'transcriptTouched', at: 5 }); + s.apply({ type: 'busy', active: true }); + s.apply({ type: 'busy', active: false, armReady: false }); + assert.equal(s.snapshot().attention, true); +}); + +test('a descriptor leaving waiting does not clear attention raised by an explicit attention event', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'attention', active: true }); + s.apply({ type: 'descriptorStatus', status: 'idle', attention: true }); + assert.equal(s.snapshot().attention, true); +}); + +test('an explicit attention:false clears descriptor-owned attention', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'attention', active: false }); + assert.equal(s.snapshot().attention, false); +}); + +test('an explicit attention event takes over from descriptor-owned attention', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + s.apply({ type: 'attention', active: true }); + s.apply({ type: 'descriptorStatus', status: 'idle', attention: true }); + assert.equal(s.snapshot().attention, true); +}); + +test('a waiting descriptor does not light attention on a session known dead', () => { + const s = createSessionState('remote-ssh'); + s.apply({ type: 'liveness', value: 'dead' }); + s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + assert.equal(s.snapshot().attention, false); +}); From 15d3bb3ad4f46221ec0bc46e73b7333e3310ae4e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:10:19 +0200 Subject: [PATCH 2/4] (remote): release descriptor attention on detach and on a host in error The handoff to a detached row projected a frozen attention left by the descriptor, and a host that dropped mid-dialog kept the row orange for the whole outage. Detach now releases descriptor-owned attention and a host in error passes attention:false with its descriptor. Rebuilt rows read the remote state at initial paint so the render no longer wipes the class, and waitingFor rejects bidi and zero-width controls. Refs #394 --- .ai/contexts/session-state.md | 16 +++++++ public/remote-activity-ui.js | 9 ++-- public/session-state.js | 4 ++ public/sidebar.js | 15 ++++++- remote-index.js | 3 ++ test/dom-sidebar-remote-freshness.test.js | 55 +++++++++++++++++++++++ test/remote-index.test.js | 8 ++++ test/remote-session-adapter.test.js | 37 ++++++++++++++- test/session-state.test.js | 16 +++++++ 9 files changed, 155 insertions(+), 8 deletions(-) diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index ba6b8933..29cbe4aa 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -254,6 +254,22 @@ it and never get attention from the descriptor. The reducer's rule: an unattached row whose descriptor disappears loses the attention at the next refresh. `applyRemoteStopped` also clears it. +Two more releases keep it from freezing: + +- The attached true to false handoff releases descriptor-owned attention. The + reducer keeps following the descriptor while a row is attached, so without + this a dialog answered in the PTY would repaint a stale orange on detach; the + next descriptor re-asserts it if the session still waits. +- A host in error keeps its last descriptors (freshness contract), so + `renderProjects` passes the project's `remoteHostError` and the descriptor + event then carries `attention: false`, which releases descriptor-owned + attention for the whole outage. A fresh host passes `attention: true`. + +The sidebar render replaces row classes from the rebuilt row (morphdom takes the +new element's classes), so `buildSessionItem` reads the unattached remote +state's attention for the row class and the icon slot, as it does for +`agentsBusy`; seeding alone would be wiped at the next render. + Attached rows are untouched: `projectRemoteState` still refuses to paint them, and the local-pty path owns their attention. The sidebar's status line appends `waitingFor` while the status is `waiting` (`waiting · permission prompt · 3m diff --git a/public/remote-activity-ui.js b/public/remote-activity-ui.js index 3f726317..d2ddcaed 100644 --- a/public/remote-activity-ui.js +++ b/public/remote-activity-ui.js @@ -159,12 +159,12 @@ function onRemoteActivityEvent(payload) { } // descriptor ports; absence stays 'unknown', never 'dead' — see session-state.md ports table -function applyRemoteDescriptor(session) { +function applyRemoteDescriptor(session, hostError) { if (!session || !session.remoteAlias) return; const state = remoteState(session.sessionId); if (session.remoteDescriptorSeen) state.apply({ type: 'liveness', value: 'alive' }); if (session.status !== undefined) { - state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt, attention: true }); + state.apply({ type: 'descriptorStatus', status: session.status, at: session.statusUpdatedAt, attention: !hostError }); } projectRemoteState(session.sessionId); } @@ -180,6 +180,7 @@ function setRemoteAttached(sessionId, attached) { clearRemoteActivityTimer(sessionId); remoteSeedFloors.set(sessionId, Date.now()); state.apply({ type: 'busy', active: false, armReady: false }); + state.apply({ type: 'releaseDescriptorAttention' }); 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'); @@ -202,9 +203,9 @@ function applyRemoteStopped(sessionId) { purgeActivityFor(sessionId, 'remote-stop'); } -function seedRemoteActivity(session) { +function seedRemoteActivity(session, hostError) { if (!session || !session.remoteAlias) return; - applyRemoteDescriptor(session); + applyRemoteDescriptor(session, hostError); if (!Number.isFinite(session.remoteActiveAt)) return; const sessionId = session.sessionId; diff --git a/public/session-state.js b/public/session-state.js index f690227c..0e61dd9a 100644 --- a/public/session-state.js +++ b/public/session-state.js @@ -73,6 +73,9 @@ function createSessionState(kind) { } touch(event); break; + case 'releaseDescriptorAttention': + releaseDescriptorAttention(); + break; case 'clearUnread': responseReady = false; break; @@ -88,6 +91,7 @@ function createSessionState(kind) { break; case 'descriptorStatus': liveness = event.status === 'alive' || event.status === 'dead' ? event.status : liveness; + if (event.attention === false) releaseDescriptorAttention(); if (event.attention === true) { if (event.status === 'waiting' && liveness !== 'dead') { clearExclusive(); diff --git a/public/sidebar.js b/public/sidebar.js index defe9260..d76e7790 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -577,7 +577,7 @@ function renderProjects(projects, resort) { 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); + for (const session of project.sessions) seedRemoteActivity(session, project.remoteHostError); } const newSidebar = document.createElement('div'); @@ -1381,6 +1381,15 @@ function rebindSidebarEvents(projects) { } } +// see .ai/contexts/session-state.md ("Descriptor-owned attention") +function remoteAttentionSnapshot(sessionId) { + if (typeof remoteSessionStates === 'undefined') return null; + const state = remoteSessionStates.get(sessionId); + if (!state) return null; + const snapshot = state.snapshot(); + return !snapshot.attached && snapshot.attention ? snapshot : null; +} + function buildSessionItem(session) { const item = document.createElement('div'); item.className = 'session-item js-stateful'; @@ -1388,7 +1397,8 @@ function buildSessionItem(session) { if (session.type === 'terminal') item.classList.add('is-terminal'); if (session.archived) item.classList.add('archived-item'); if (activePtyIds.has(session.sessionId)) item.classList.add('has-running-pty'); - setNeedsAttention(item, attentionSessions.has(session.sessionId)); + const remoteAttention = remoteAttentionSnapshot(session.sessionId); + setNeedsAttention(item, attentionSessions.has(session.sessionId) || !!remoteAttention); setResponseReady(item, responseReadySessions.has(session.sessionId)); setCliBusy(item, !!sessionBusyState.get(session.sessionId)); setHasBusyAgents(item, parentHasActiveSubagent(session.sessionId)); @@ -1416,6 +1426,7 @@ function buildSessionItem(session) { const icon = document.createElement('span'); icon.className = 'session-icon' + (activePtyIds.has(session.sessionId) ? ' running' : ''); paintSessionIcon(icon, session.sessionId, session); + if (remoteAttention) writeIconSlot(icon, renderSessionIcon(remoteAttention)); // Info block const info = document.createElement('div'); diff --git a/remote-index.js b/remote-index.js index 729e9ff9..64039d2d 100644 --- a/remote-index.js +++ b/remote-index.js @@ -42,6 +42,9 @@ function sanitizeWaitingFor(value) { for (let k = 0; k < text.length; k++) { const code = text.charCodeAt(k); if (code < 32 || code === 127) return null; + if (code >= 0x200b && code <= 0x200f) return null; + if (code >= 0x202a && code <= 0x202e) return null; + if (code >= 0x2066 && code <= 0x2069) return null; } return text; } diff --git a/test/dom-sidebar-remote-freshness.test.js b/test/dom-sidebar-remote-freshness.test.js index 1e493d6c..36c9cb5a 100644 --- a/test/dom-sidebar-remote-freshness.test.js +++ b/test/dom-sidebar-remote-freshness.test.js @@ -198,3 +198,58 @@ test('a host genuinely without any live session is distinct from a host with a l assert.match(emptyDot.title, /no live session/i); } finally { ctx.destroy(); } }); + +test('a waiting remote session is orange on a fresh host and not on a host in error', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'wait-1', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, + status: 'waiting', statusUpdatedAt: 1, waitingFor: 'permission prompt', + }; + const project = (remoteHostError) => remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', + remoteHostAt: 1, remoteHostError, sessions: [{ ...session }], + }); + const orange = () => ctx.document.querySelector('.session-item[data-session-id="wait-1"]').classList.contains('needs-attention'); + ctx.sidebar.renderProjects([project(null)], true); + assert.equal(orange(), true); + ctx.sidebar.renderProjects([project('ssh: timed out')], true); + assert.equal(orange(), false); + } finally { ctx.destroy(); } +}); + +test('a rebuilt waiting remote row keeps its attention icon through the morphdom render', () => { + const ctx = setupSidebarDom(); + try { + const project = remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', remoteHostAt: 1, + sessions: [{ + sessionId: 'wait-2', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, status: 'waiting', + }], + }); + ctx.sidebar.renderProjects([project], true); + ctx.sidebar.renderProjects([project], true); + const icon = ctx.document.querySelector('.session-item[data-session-id="wait-2"] .session-icon'); + assert.ok(icon.classList.contains('session-icon--attention')); + } finally { ctx.destroy(); } +}); + +test('an attached waiting remote row is not painted attention by the rebuilt row', () => { + const ctx = setupSidebarDom(); + try { + const project = remoteProject({ + projectPath: '/srv/w', folder: 'planificator::-srv-w', remoteAlias: 'planificator', remoteHostAt: 1, + sessions: [{ + sessionId: 'wait-3', summary: 'waiting', modified: '2026-09-09T11:59:00.000Z', starred: false, + archived: 0, messageCount: 1, remoteAlias: 'planificator', remoteDescriptorSeen: true, status: 'waiting', + }], + }); + ctx.sidebar.renderProjects([project], true); + ctx.window.setRemoteAttached('wait-3', true); + ctx.sidebar.renderProjects([project], true); + const item = ctx.document.querySelector('.session-item[data-session-id="wait-3"]'); + assert.ok(!item.classList.contains('needs-attention')); + } finally { ctx.destroy(); } +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index c3a44e7e..13ed5e92 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -928,3 +928,11 @@ test('the indexer and the placeholder keep a validated waitingFor beside the sta assert.equal(ph.get('b').waitingFor, null); } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); + +test('sanitizeWaitingFor rejects bidi and zero-width controls', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + for (const code of [0x200b, 0x200f, 0x202a, 0x202e, 0x2066, 0x2069]) { + assert.equal(sanitizeWaitingFor('a' + String.fromCharCode(code) + 'b'), null, code.toString(16)); + } + assert.equal(sanitizeWaitingFor('caf\u00e9 \u2014 ok'), 'caf\u00e9 \u2014 ok'); +}); diff --git a/test/remote-session-adapter.test.js b/test/remote-session-adapter.test.js index a35169f6..95605b3f 100644 --- a/test/remote-session-adapter.test.js +++ b/test/remote-session-adapter.test.js @@ -76,7 +76,7 @@ function setup(sessionIds = ['s1']) { snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), applyRemoteDescriptor: (session) => call('applyRemoteDescriptor', session), setRemoteAttached: (id, attached) => call('setRemoteAttached', id, attached), - seedRemoteActivity: (session) => call('seedRemoteActivity', session), + seedRemoteActivity: (session, hostError) => call("seedRemoteActivity", session, hostError === undefined ? null : hostError), scheduled, pending: () => scheduled.filter(h => !h.cleared), destroy: () => window.close(), @@ -275,7 +275,7 @@ function setupWithClock(sessionIds = ['s1']) { now: () => clock, emit: (payload) => onRemoteActivityCb(payload), snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), - seedRemoteActivity: (session) => call('seedRemoteActivity', session), + seedRemoteActivity: (session, hostError) => call("seedRemoteActivity", session, hostError === undefined ? null : hostError), advance(ms) { clock += ms; for (const t of timers) { @@ -439,3 +439,36 @@ test('a decayed busy edge does not erase a waiting attention', () => { assert.equal(t.snapshot('s1').attention, true); t.destroy(); }); + +test('a waiting descriptor while attached paints nothing', () => { + const t = setup(['s1']); + t.setRemoteAttached('s1', true); + t.applyRemoteDescriptor(waitingSession()); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('attach then detach with no new descriptor does not show the stale attention; a later waiting descriptor lights it again', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + t.setRemoteAttached('s1', true); + t.applyRemoteDescriptor(waitingSession()); + t.setRemoteAttached('s1', false); + assert.equal(t.snapshot('s1').attention, false); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession()); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); + +test('a host in error releases the attention its last descriptors raised, and a fresh host restores it', () => { + const t = setup(['s1']); + t.seedRemoteActivity(waitingSession()); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession(), 'ssh: connection refused'); + assert.equal(t.snapshot('s1').attention, false); + assert.ok(!t.item('s1').classList.contains('needs-attention')); + t.seedRemoteActivity(waitingSession(), null); + assert.ok(t.item('s1').classList.contains('needs-attention')); + t.destroy(); +}); diff --git a/test/session-state.test.js b/test/session-state.test.js index 0aa5358c..e330195f 100644 --- a/test/session-state.test.js +++ b/test/session-state.test.js @@ -330,3 +330,19 @@ test('a waiting descriptor does not light attention on a session known dead', () s.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); assert.equal(s.snapshot().attention, false); }); + +test('descriptorStatus attention:false and releaseDescriptorAttention release only descriptor-owned attention', () => { + for (const release of [ + (s) => s.apply({ type: 'descriptorStatus', status: 'waiting', attention: false }), + (s) => s.apply({ type: 'releaseDescriptorAttention' }), + ]) { + const owned = createSessionState('remote-ssh'); + owned.apply({ type: 'descriptorStatus', status: 'waiting', attention: true }); + release(owned); + assert.equal(owned.snapshot().attention, false); + const explicit = createSessionState('remote-ssh'); + explicit.apply({ type: 'attention', active: true }); + release(explicit); + assert.equal(explicit.snapshot().attention, true); + } +}); From abc03ca6e7e4f2cb29da8738fb9c2a99070b238c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:21:55 +0200 Subject: [PATCH 3/4] (remote): notify when a host's error changes and widen the waitingFor filter A host that started failing or recovered with no file changes triggered no re-render, so the host-error gate on attention waited for an unrelated render. The indexer now notifies when a host's last error changes, once per change. waitingFor also rejects C1 controls, soft hyphen, ALM, line and paragraph separators, invisible operators and the BOM. Refs #394 --- .ai/contexts/session-state.md | 3 ++ remote-index.js | 16 +++++-- test/remote-index.test.js | 65 +++++++++++++++++++++++++++++ test/remote-session-adapter.test.js | 4 +- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 29cbe4aa..ba6da1b8 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -264,6 +264,9 @@ Two more releases keep it from freezing: `renderProjects` passes the project's `remoteHostError` and the descriptor event then carries `attention: false`, which releases descriptor-owned attention for the whole outage. A fresh host passes `attention: true`. + The indexer notifies the renderer when a host's last error changes (first + failure, a different error, recovery), not only when files changed, so the + gate applies without an unrelated render. The sidebar render replaces row classes from the rebuilt row (morphdom takes the new element's classes), so `buildSessionItem` reads the unattached remote diff --git a/remote-index.js b/remote-index.js index 64039d2d..a1c2da49 100644 --- a/remote-index.js +++ b/remote-index.js @@ -45,6 +45,10 @@ function sanitizeWaitingFor(value) { if (code >= 0x200b && code <= 0x200f) return null; if (code >= 0x202a && code <= 0x202e) return null; if (code >= 0x2066 && code <= 0x2069) return null; + if (code >= 0x80 && code <= 0x9f) return null; + if (code === 0xad || code === 0x61c || code === 0x180e || code === 0xfeff) return null; + if (code === 0x2028 || code === 0x2029) return null; + if (code >= 0x2060 && code <= 0x2064) return null; } return text; } @@ -116,15 +120,18 @@ function createRemoteIndexer(ctx) { if (state.failures > 0) { log.info(`[remote:${alias}] refresh recovered after ${state.failures} consecutive failure(s)`); } + const errorChanged = state.lastError !== null; state.failures = 0; state.lastError = null; state.nextAttemptAt = 0; + return errorChanged; } function onHostFailure(alias, err, intervalMs) { const state = backoffState(alias); const prevDelay = backoffDelayMs(state.failures, intervalMs); state.failures += 1; + const errorChanged = state.lastError !== err.message; state.lastError = err.message; const delay = backoffDelayMs(state.failures, intervalMs); state.nextAttemptAt = now() + delay; @@ -132,6 +139,7 @@ function createRemoteIndexer(ctx) { log.warn(`[remote:${alias}] refresh failed (${state.failures}x consecutive): ${err.message}; ` + `retrying in ${Math.round(delay / 1000)}s`); } + return errorChanged; } function getRemoteHostState(alias) { @@ -272,13 +280,13 @@ function createRemoteIndexer(ctx) { } try { if (await refreshHost(host)) changed = true; - onHostSuccess(host.alias); + if (onHostSuccess(host.alias)) changed = true; remoteSessionsAt.set(host.alias, now()); } catch (err) { // A failed cycle keeps the last known descriptors — see // .ai/contexts/session-cache.md ("Remote hosts — freshness contract"). errors.push({ alias: host.alias, error: err.message }); - onHostFailure(host.alias, err, intervalMs); + if (onHostFailure(host.alias, err, intervalMs)) changed = true; } } } finally { @@ -308,12 +316,12 @@ function createRemoteIndexer(ctx) { let error = null; try { changed = await refreshHost(host); - onHostSuccess(alias); + if (onHostSuccess(alias)) changed = true; remoteSessionsAt.set(alias, now()); } catch (err) { // A failed cycle keeps the last known descriptors — see // .ai/contexts/session-cache.md ("Remote hosts — freshness contract"). - onHostFailure(alias, err, intervalMs); + if (onHostFailure(alias, err, intervalMs)) changed = true; error = err.message; } finally { hostInFlight.delete(alias); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index 13ed5e92..e87b14ff 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -936,3 +936,68 @@ test('sanitizeWaitingFor rejects bidi and zero-width controls', () => { } assert.equal(sanitizeWaitingFor('caf\u00e9 \u2014 ok'), 'caf\u00e9 \u2014 ok'); }); + +test('sanitizeWaitingFor rejects C1 controls, soft hyphen, ALM, invisible operators and BOM', () => { + const { sanitizeWaitingFor } = require('../remote-index'); + for (const code of [0x80, 0x9f, 0xad, 0x61c, 0x180e, 0x2028, 0x2029, 0x2060, 0x2064, 0xfeff]) { + assert.equal(sanitizeWaitingFor('a' + String.fromCharCode(code) + 'b'), null, code.toString(16)); + } + assert.equal(sanitizeWaitingFor('a\u00a0b'), 'a\u00a0b', 'a no-break space is plain text'); +}); + +function hostFlipIndexer(notifyCalls, outcome) { + const dataDir = tmp('idx-notify'); + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + notify: () => { notifyCalls.push(1); }, + timers: fakeTimers(), + sync: async () => { + const o = outcome.next; + if (o instanceof Error) throw o; + return { fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, changedFolders: new Set(), sessions: [] }; + }, + }); + return { indexer, dataDir }; +} + +test('a host that starts failing, changes its error or recovers notifies once per change, with no file changes', async () => { + const calls = []; + const outcome = { next: null }; + const { indexer, dataDir } = hostFlipIndexer(calls, outcome); + try { + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 0, 'a quiet healthy host does not notify'); + outcome.next = new Error('ssh: timed out'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 1, 'the first failure notifies'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 1, 'the same error again does not'); + outcome.next = new Error('ssh: refused'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 2, 'a different error notifies'); + outcome.next = null; + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 3, 'recovery notifies'); + await indexer.refreshNow({ force: true }); + assert.equal(calls.length, 3, 'staying healthy does not'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +test('refreshHostNow notifies on a host error change too', async () => { + const calls = []; + const outcome = { next: new Error('ssh: timed out') }; + const { indexer, dataDir } = hostFlipIndexer(calls, outcome); + try { + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 1); + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 1); + outcome.next = null; + await indexer.refreshHostNow('vps', { force: true }); + assert.equal(calls.length, 2); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); diff --git a/test/remote-session-adapter.test.js b/test/remote-session-adapter.test.js index 95605b3f..8b4dee2a 100644 --- a/test/remote-session-adapter.test.js +++ b/test/remote-session-adapter.test.js @@ -76,7 +76,7 @@ function setup(sessionIds = ['s1']) { snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), applyRemoteDescriptor: (session) => call('applyRemoteDescriptor', session), setRemoteAttached: (id, attached) => call('setRemoteAttached', id, attached), - seedRemoteActivity: (session, hostError) => call("seedRemoteActivity", session, hostError === undefined ? null : hostError), + seedRemoteActivity: (session, hostError) => call('seedRemoteActivity', session, hostError === undefined ? null : hostError), scheduled, pending: () => scheduled.filter(h => !h.cleared), destroy: () => window.close(), @@ -275,7 +275,7 @@ function setupWithClock(sessionIds = ['s1']) { now: () => clock, emit: (payload) => onRemoteActivityCb(payload), snapshot: (id) => vm.runInContext(`remoteState(${JSON.stringify(id)}).snapshot()`, ctx), - seedRemoteActivity: (session, hostError) => call("seedRemoteActivity", session, hostError === undefined ? null : hostError), + seedRemoteActivity: (session, hostError) => call('seedRemoteActivity', session, hostError === undefined ? null : hostError), advance(ms) { clock += ms; for (const t of timers) { From 4ba42670685ac131d903d8e68e33663b4d35d3c7 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:48:35 +0200 Subject: [PATCH 4/4] (remote): paint the remote attention icon through session-activity-dom sidebar.js called writeIconSlot, which is not a declared cross-file global and which only session-activity-dom.js may call. Add paintSessionIconFromSnapshot there, declare it in eslint.config.js and use it from the sidebar. Refs #394 --- eslint.config.js | 1 + public/session-activity-dom.js | 6 ++++++ public/sidebar.js | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 6a2a948c..197dc291 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -173,6 +173,7 @@ const rendererCrossFileGlobals = { setIsAlive: 'readonly', isSessionAlive: 'readonly', paintSessionIcon: 'readonly', + paintSessionIconFromSnapshot: 'readonly', // public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md) setRemoteAttached: 'readonly', applyRemoteStopped: 'readonly', diff --git a/public/session-activity-dom.js b/public/session-activity-dom.js index 1a256f05..c727a514 100644 --- a/public/session-activity-dom.js +++ b/public/session-activity-dom.js @@ -74,6 +74,12 @@ function snapshotForLocal(sessionId, session) { return state.snapshot(); } +// Paints an icon slot from a snapshot held by another adapter — see .ai/contexts/session-state.md ("Descriptor-owned attention") +function paintSessionIconFromSnapshot(el, snapshot) { + if (!el) return; + writeIconSlot(el, renderSessionIcon(snapshot)); +} + // Paints a local-pty row's icon slot — see .ai/contexts/session-state.md function paintSessionIcon(el, sessionId, session) { if (!el) return; diff --git a/public/sidebar.js b/public/sidebar.js index d76e7790..7d39c731 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1426,7 +1426,7 @@ function buildSessionItem(session) { const icon = document.createElement('span'); icon.className = 'session-icon' + (activePtyIds.has(session.sessionId) ? ' running' : ''); paintSessionIcon(icon, session.sessionId, session); - if (remoteAttention) writeIconSlot(icon, renderSessionIcon(remoteAttention)); + if (remoteAttention) paintSessionIconFromSnapshot(icon, remoteAttention); // Info block const info = document.createElement('div');