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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ai/contexts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ without re-reading `main.js`, now ~2600 LOC.
| New IPC, preload bridge changes, renderer ↔ main protocol | [ipc-bridge](ipc-bridge.md) |
| File-trigger watcher, harness input injection, idle-wait | [trigger-watcher](trigger-watcher.md) |
| Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) |
| Busy/attention/response-ready state, the session-state domain module, the icon-slot projection | [session-state](session-state.md) |

## Reading order for a new contributor (~30 min)

Expand Down
125 changes: 125 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Session state — one domain module, one icon slot

Origin: issue #246 (step 3 of the alignment sequence #244 → #245 → #246 → #247).
Full design: the issue body and its 2026-09-11 lifecycle comment. This doc covers
what actually shipped, not the whole plan.

## Migration status

- **Steps 1-2: done.** `public/session-activity.js` split into a state part
(itself) and a DOM part (`public/session-activity-dom.js`); `public/session-state.js`
introduced and wired behind `applyActivityClasses` for **local-pty only**.
- **Steps 3-5: pending.** `remote-activity-ui.js`/`remote-activity.js` still write
`sessionBusyState` directly instead of going through a `remote-ssh` adapter; there
is no `local-transcript` adapter; subagent attribution is not routed through
`session-state.js` (`agentsBusy` exists in the model but nothing local-pty feeds
it yet — sidebar.js's `has-busy-agents` is still computed by
`parentHasActiveSubagent()`, independent of the domain module).

## Shape

Three files, one direction of dependency for data, the reverse for rendering:

```
public/session-state.js pure domain — no DOM, no IPC, no electron
public/session-activity-dom.js DOM projection — the only file allowed to
write .cli-busy/.needs-attention/
.response-ready/.has-busy-agents
public/session-activity.js Maps/Sets + setActivity/purgeActivityFor/
rekeyActivityState/reconcileBusyState —
calls into session-activity-dom.js to render
```

`createSessionState(kind)` returns `{ apply(event), snapshot() }`. `kind` is
`'local-pty' | 'local-transcript' | 'remote-ssh'` (only `'local-pty'` is fed
today). Snapshot fields:

| field | meaning |
|---|---|
| `kind` | which adapter produced this state |
| `liveness` | `'alive' \| 'dead' \| 'unknown'` — is the CLI process running |
| `attached` | Switchboard holds a PTY / ssh attach for it — **separate from liveness** (2026-09-11 lifecycle decision: a row is active because the process is alive, not because a tab is open) |
| `busy` | OSC 0 — generating |
| `waitingForInput` | idle, sitting at the prompt |
| `attention` | OSC 9 — needs the user right now (permission/approval/plan) |
| `responseReady` | subset of `waitingForInput`: idle **and** unseen when it went idle (the legacy "Claude finished, you haven't looked" rung). Not in the issue's original field list — added because the priority order names it as its own rung, distinct from plain `waitingForInput`; see "Design notes" below. |
| `agentsBusy` | subagents running under this session |
| `lastActivityAt` / `lastActivitySource` | last touch, for `local-transcript`/`remote-ssh` (unused by local-pty today) |
| `label` / `labelConfidence` | reserved, unused |
| `attachable` | reserved, unused |
| `archived` / `stale` | reserved, unused |

Invariant enforced by `apply()`: `busy` / `waitingForInput` / `attention` are
mutually exclusive — going busy or attention clears the other two (and
`responseReady`, which only means something under `waitingForInput`).

`renderSessionIcon(snapshot)` resolves the priority order — attention >
responseReady > busy > agentsBusy > waitingForInput > idle+age > stale >
archived — defensively (it does not trust the caller kept exclusivity) and
returns `{ classes, glyph, title }` for **one icon slot**. Only the four
rungs that map to an existing CSS class (`needs-attention`, `response-ready`,
`cli-busy`, `has-busy-agents`) carry a class today; the rest carry a glyph/title
only — the sidebar HTML/CSS shape (replacing the dot/pip with the icon slot)
is a later step, not part of this migration.

## Design notes (deviations from the issue's literal text)

- **`responseReady` added to the snapshot.** The issue's field list didn't
include it, but the priority order names "response-ready" as a rung distinct
from `waitingForInput` — impossible to reproduce with one boolean. Modeled
as `waitingForInput`'s narrower subset (idle + not yet seen when it went
idle), set via `apply({ type: 'busy', active: false, armReady })` — direct
translation of the pre-existing `setActivity(id, active, via, { armReady })`
contract in `session-activity.js`.
- **"Seen" (today's `activeSessionId` focus check) stays a caller decision,
not a domain fact.** `attached` (introduced 2026-09-11) means "Switchboard
holds a PTY for it", true for every open tab, not just the focused one — it
cannot stand in for "the user is looking at this row right now". The
`armReady` flag on the `busy` event carries that judgment in from the
adapter, same as before the split.
- Ports (`transcriptTouched`, `descriptorStatus`, `subagentSpawned/Completed`,
`attachable`, `label`, `archived`, `stale`) are implemented in `apply()` but
**not fed by any adapter yet** — they exist so steps 3-5 don't need another
domain-shape change.

## Enforcement

- `eslint.config.js`: `no-restricted-syntax` selectors, in every `public/**/*.js`
file except `session-activity-dom.js` (tests are a separate glob, exempt by
construction), forbid the four class names (`cli-busy`, `needs-attention`,
`response-ready`, `has-busy-agents`) in: `classList.add/remove/toggle/replace`
(string or template literal), `className` / `innerHTML` / `outerHTML`
assignments, `setAttribute(...)` and `insertAdjacentHTML(...)`; any computed
`classList[method](...)` call is refused outright because it hides the name.
Verified 2026-09-11 with a probe file: six bypass shapes red, an unrelated
class name green, 0 errors on the real renderer. Not caught, by nature: a
class name held in a variable or built by concatenation — a review item, not
a lint item. All prior direct writers (`app.js`, `sidebar.js`,
`session-activity.js` itself) were moved onto the DOM file's
`setNeedsAttention`/`setResponseReady`/`setCliBusy`/`setHasBusyAgents`
helpers so the rules start at zero violations.
- `session-activity-dom.js` resolves a busy + response-ready tie as busy
(`main` resolved it as response-ready). The tie is unreachable: `setActivity`
and `rekeyActivityState` keep the two sets exclusive before projection. Noted
so a future invariant break is read as such, not as a projection bug.
- `test/session-state-boundary.test.js`: source-grep (no `require()`, same
shape as `test/main-ctx-db-wiring.test.js`) asserting `session-state.js`
never references `document`, `window`, `require('electron')` or `ipcRenderer`.
- `test/session-state.test.js`: apply-sequence, exclusivity, priority order
(mutated once during development — reordering `PRIORITY` to put `agentsBusy`
first turned the three top-rung priority tests red; reverted), and
`renderSessionIcon` per rung.

## Ports table (target shape, not all wired yet)

| event | local-pty | local-transcript | remote-ssh |
|---|---|---|---|
| `busy` / `attention` (OSC 0 / 9) | yes | never | only while attached |
| `transcriptTouched(at)` | yes | yes (only signal) | yes (watch channel) |
| `descriptorStatus(status, at)` | yes | no (no live CLI) | yes (`main.js:539`) |
| `subagentSpawned` / `subagentCompleted` | yes | no | no today |

An adapter without a PTY must never claim `waitingForInput` or `responseReady`
— it has no way to tell "thinking" from "done, unseen". It should only feed
`busy: unknown` (not modeled as a tri-state yet — reserved for step 3/4) plus
`lastActivityAt`.
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A
| Change SQLite, indexing, watcher, FTS, heatmap | [contexts/session-cache.md](contexts/session-cache.md) |
| Change schedule cron / `.md` files / schedule spawn | [contexts/schedule-runner.md](contexts/schedule-runner.md) |
| Change subagent grouping, transcript view, parent→child | [contexts/subagent-observability.md](contexts/subagent-observability.md) |
| Change busy/attention/response-ready state or the session-state domain module | [contexts/session-state.md](contexts/session-state.md) |
| Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) |
| Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) |
| Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` |
Expand Down
76 changes: 76 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
// Main-process files (CommonJS) get a separate block with node globals.

const globals = require('globals');
const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .cli-busy/.needs-attention/.response-ready/.has-busy-agents — see .ai/contexts/session-state.md';

// Cross-file renderer globals: vars defined in one file and consumed by
// another. The list mirrors the dependency comment at the top of
Expand Down Expand Up @@ -149,6 +150,16 @@ const rendererCrossFileGlobals = {
forgetActivitySeq: 'readonly',
purgeActivityFor: 'readonly',
pruneRemoteActivityTimers: 'readonly',
// public/session-state.js (pure domain, see .ai/contexts/session-state.md)
createSessionState: 'readonly',
renderSessionIcon: 'readonly',
// public/session-activity-dom.js — the only file allowed to write
// .cli-busy/.needs-attention/.response-ready/.has-busy-agents.
applyActivityClassesToElement: 'readonly',
setNeedsAttention: 'readonly',
setResponseReady: 'readonly',
setCliBusy: 'readonly',
setHasBusyAgents: 'readonly',

// Third-party renderer libs loaded as <script>
morphdom: 'readonly',
Expand Down Expand Up @@ -331,6 +342,71 @@ module.exports = [
},
},

// Dual-mode pure domain module (public/session-state.js — see
// .ai/contexts/session-state.md): classic <script> in the renderer,
// require()-d in node:test with no DOM/window/electron. It declares
// createSessionState/renderSessionIcon rather than consuming them, so
// those two globals are switched off here (same reasoning as
// subagent-timing.js above).
{
files: ['public/session-state.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'script',
globals: {
module: 'writable',
createSessionState: 'off',
renderSessionIcon: 'off',
},
},
rules: {
'no-undef': 'error',
'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }],
'no-redeclare': 'warn',
},
},

// Enforcement (.ai/contexts/session-state.md, migration step 3): only
// public/session-activity-dom.js may write the four activity classes.
// Every other public/**/*.js file is checked; tests are exempt (they
// assert on these classes directly, e.g. `item.classList.contains(...)`).
{
files: ['public/**/*.js'],
ignores: ['public/session-activity-dom.js'],
rules: {
'no-restricted-syntax': ['error',
{
selector: "CallExpression[callee.object.property.name='classList'][callee.property.name=/^(add|remove|toggle|replace)$/] > Literal[value=/^(cli-busy|needs-attention|response-ready|has-busy-agents)$/]",
message: ACTIVITY_CLASS_MESSAGE,
},
{
selector: "CallExpression[callee.object.property.name='classList'][callee.property.name=/^(add|remove|toggle|replace)$/] > TemplateLiteral > TemplateElement[value.raw=/(cli-busy|needs-attention|response-ready|has-busy-agents)/]",
message: ACTIVITY_CLASS_MESSAGE,
},
{
selector: "CallExpression[callee.object.property.name='classList'][callee.computed=true]",
message: 'Computed classList[method](...) hides the class name from lint; call add/remove/toggle directly. See .ai/contexts/session-state.md',
},
{
selector: "AssignmentExpression[left.property.name=/^(className|innerHTML|outerHTML)$/] Literal[value=/(cli-busy|needs-attention|response-ready|has-busy-agents)/]",
message: ACTIVITY_CLASS_MESSAGE,
},
{
selector: "AssignmentExpression[left.property.name=/^(className|innerHTML|outerHTML)$/] TemplateElement[value.raw=/(cli-busy|needs-attention|response-ready|has-busy-agents)/]",
message: ACTIVITY_CLASS_MESSAGE,
},
{
selector: "CallExpression[callee.property.name=/^(setAttribute|insertAdjacentHTML)$/] Literal[value=/(cli-busy|needs-attention|response-ready|has-busy-agents)/]",
message: ACTIVITY_CLASS_MESSAGE,
},
{
selector: "CallExpression[callee.property.name=/^(setAttribute|insertAdjacentHTML)$/] TemplateElement[value.raw=/(cli-busy|needs-attention|response-ready|has-busy-agents)/]",
message: ACTIVITY_CLASS_MESSAGE,
},
],
},
},

// CodeMirror setup file uses ESM-style imports/closure that don't lint well
// as a classic script — keep no-undef on but be permissive about unused.
{
Expand Down
9 changes: 4 additions & 5 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,7 @@ function clearNotifications(sessionId) {
clearUnread(sessionId);
if (window.ATRACE && attentionSessions.has(sessionId)) window.atrace('store.mutate', sessionId, { map: 'attentionSessions', op: 'delete', from: true, to: false, fn: 'clearNotifications' });
attentionSessions.delete(sessionId);
const item = document.querySelector(`.session-item[data-session-id="${sessionId}"]`);
if (item) item.classList.remove('needs-attention');
setNeedsAttention(sessionItemEl(sessionId), false);
}
// Terminal themes, utils (cleanDisplayName, formatDate, escapeHtml, shellEscape)
// are defined in terminal-themes.js and utils.js (loaded before app.js).
Expand Down Expand Up @@ -463,9 +462,9 @@ window.api.onTerminalNotification((sessionId, message) => {
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 = document.querySelector(`.session-item[data-session-id="${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' });
if (item) item.classList.add('needs-attention');
setNeedsAttention(item, true);
} 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 @@ -815,7 +814,7 @@ function updateRunningIndicators() {
item.classList.toggle('has-running-pty', running);
// remote rows are owned by the remote adapter — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner")
if (!running && !item.dataset.remoteAlias) {
item.classList.remove('has-busy-agents');
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
Expand Down
5 changes: 3 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@
<!-- shortcuts.js defines matchShortcut/appShortcuts helpers used by terminal-manager,
grid-view, app and settings-panel — must load before those consumers. -->
<script src="shortcuts.js"></script>
<!-- session-activity.js owns the busy/response-ready/attention state read by
terminal-manager, grid-view, sidebar and app — must load before them. -->
<!-- session-state → session-activity-dom → session-activity: order matters, see .ai/contexts/session-state.md -->
<script src="session-state.js"></script>
<script src="session-activity-dom.js"></script>
<script src="session-activity.js"></script>
<script src="terminal-themes.js"></script>
<script src="terminal-context-menu.js"></script>
Expand Down
46 changes: 46 additions & 0 deletions public/session-activity-dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// DOM projection for session activity, sole writer of the activity classes — see .ai/contexts/session-state.md

function sessionItemEl(sessionId) {
return document.querySelector(`.session-item[data-session-id="${sessionId}"]`);
}

function setNeedsAttention(el, on) {
if (el) el.classList.toggle('needs-attention', !!on);
}

function setResponseReady(el, on) {
if (el) el.classList.toggle('response-ready', !!on);
}

function setCliBusy(el, on) {
if (el) el.classList.toggle('cli-busy', !!on);
}

function setHasBusyAgents(el, on) {
if (el) el.classList.toggle('has-busy-agents', !!on);
}

// 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);
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);
}
Loading
Loading