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
158 changes: 145 additions & 13 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ what actually shipped, not the whole plan.

## Migration status

- **Steps 1-3: done.** `public/session-activity.js` split into a state part
- **Steps 1-3b: 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, and behind
a persistent `remote-ssh` adapter (`public/remote-activity-ui.js`) for
remote sessions — see "The remote-ssh adapter" below.
- **Steps 3b/4/5: pending.** The unified icon-slot markup (dot + age + spinner
in one element) is a separate PR (3b). There is no `local-transcript`
adapter (step 4). 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) (step 5).
remote sessions — see "The remote-ssh adapter" below. Step 3b (one
`.session-icon` slot per sidebar row, replacing `.session-status-dot` for
session/subagent rows) shipped separately — see "The icon slot (step 3b)"
below.
- **Steps 4/5: pending.** There is no `local-transcript` adapter (step 4).
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` row class is still computed by `parentHasActiveSubagent()`,
independent of the domain module) (step 5).

### The remote-ssh adapter (step 3)

Expand Down Expand Up @@ -104,11 +106,141 @@ mutually exclusive — going busy or attention clears the other two (and
`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.
returns `{ classes, slotClasses, glyph, title }` for **one icon slot**. Only
the four rungs that map to an existing row-level CSS class (`needs-attention`,
`response-ready`, `cli-busy`, `has-busy-agents`, in `classes`) carry one; every
rung — including those four — also carries exactly one `slotClasses` entry
(`session-icon--attention`, `session-icon--response-ready`, `session-icon--busy`,
`session-icon--agents-busy`, `session-icon--waiting`, `session-icon--idle`,
`session-icon--stale`, `session-icon--archived`). See "The icon slot (step 3b)"
below for how `classes` and `slotClasses` are used differently.

## The icon slot (step 3b)

One `.session-icon` element per session/subagent row (`public/sidebar.js`'s
`buildSessionItem`/`buildSubagentItem`), replacing `.session-status-dot` for
those two rows — `.remote-host-dot` (the project header's per-host
reachability indicator, a different row entirely) is untouched and still
composes `class="session-status-dot remote-host-dot ..."`; both base CSS
rules (`.session-status-dot`, `.session-icon`) exist side by side in
`style.css` for exactly this reason. `.session-icon`'s box (6px, same as the
old dot) is fixed regardless of rung, so the row never shifts as the glyph
underneath it changes.

`session-activity-dom.js` is the only file that writes the slot:

- `writeIconSlot(el, icon)` clears any previous `session-icon--*` class
(robust to a rung's `slotClasses` shrinking or changing shape — a plain
diff of the old vs. new class would also work but this needs no diffing),
applies `icon.slotClasses`, and sets `el.title`/`el.dataset.glyph` from
`icon.title`/`icon.glyph`. The `session-icon--*` namespace is deliberately
distinct from the row-level `classes` (`cli-busy` etc.) both for the eslint
boundary (see "Enforcement" below) and so a reader never confuses "this
paints the row" with "this paints the slot".
- `session-activity-dom.js` also references `parentHasActiveSubagent`
(`sidebar.js`) and `sessionMap` (`app.js`) now, alongside the pre-existing
`sessionBusyState`/`responseReadySessions`/`attentionSessions`
(`session-activity.js`). Safe despite loading before all three in
index.html's script order — every reference is inside a function body,
resolved at call time after the whole page has loaded, same pattern
`sidebar.js`'s own header comment documents for its dependencies.
- `snapshotForLocal(sessionId, session)` builds a local-pty snapshot the same
way `computeBusyReadyClasses` does for busy/responseReady, extended with
`attention` (`attentionSessions`), `agentsBusy` (`parentHasActiveSubagent()`),
and `liveness`/`descriptorStatus` from `session.status`/`statusUpdatedAt`
when present — cli-session-state.js only keeps an entry while the pid is
alive (`.ai/contexts/cli-session-state.md`), so `session.status` being
present at all is itself the local liveness signal; `session` is optional
and falls back to a `sessionMap` lookup for call sites that only have a
sessionId.
- `paintSessionIcon(el, sessionId, session)` composes the two:
`writeIconSlot(el, renderSessionIcon(snapshotForLocal(sessionId, session)))`.
Called from `sidebar.js` at row construction (both `buildSessionItem` and
`buildSubagentItem`), from `applyActivityClassesToElement` on every local
busy/ready/attention/subagent transition, and from
`reflectSubagentRunningState` on the **parent** row (agentsBusy is part of
the priority ladder the slot resolves, so a subagent spawn/complete must
repaint the parent's slot, not just its `has-busy-agents` row class).
- `applyStateClasses(sessionId, snapshot)` (the remote-ssh path, called from
`remote-activity-ui.js`'s `projectRemoteState`) now also calls
`writeIconSlot` with the same `renderSessionIcon(snapshot)` result it uses
for the row's `cli-busy`/`response-ready` classes — this is what makes a
local busy row and a remote busy row render the identical slot markup
(classes, title, glyph), pinned in `test/dom-sidebar-icon-slot.test.js`.

The `.running` class on `.session-icon` is **not** part of this model — it is
the old dot's orthogonal "a PTY/subagent process is attached" boolean
(`activePtyIds`/`isSubagentActive`), toggled exactly as before by
`app.js`/`sidebar.js`, independent of `renderSessionIcon`'s priority ladder.

### The slot's CSS keys on its own rung class alone (coordinator follow-up, 2026-09-11)

First pass of this step left `style.css` still resolving the slot's visual
with row-class `:not()` chains carried over verbatim from the old
`.session-status-dot` rules (`.session-item.cli-busy:not(.needs-attention)
.session-icon`, `.session-item.has-busy-agents:not(.cli-busy):not(...):not(...)
.session-icon`) — exactly the row-class arbitration this migration exists to
retire, just renamed. `renderSessionIcon()` already picks the single active
rung in JS before `writeIconSlot()` ever touches the DOM, so the CSS never
needs to re-derive it: every rung's visual now keys on its own
`.session-icon--<rung>` class alone, no ancestor `:not()` chain —

```css
.session-icon--busy { background: transparent !important; animation: none !important; ... }
.session-icon--busy::before { content: "\280B"; animation: braille-spin ...; color: #4fc3f7; ... }
.session-icon--agents-busy { background: transparent !important; ... }
.session-icon--agents-busy::before { content: "\283F"; color: #8088ff; ... }
.session-icon--response-ready { background: #4fc3f7 !important; ... }
.session-icon--attention { background: #f0a050 !important; ... }
```

`background` (the only property `.session-icon.running`'s plain green also
sets) carries `!important` on every rung — busy/attention/response-ready/
agentsBusy rows are very often also `.running` (a busy session almost always
has a live PTY), and unlike the old code the rung is now a single class with
no extra row-ancestor classes to lean on for specificity.

**`has-busy-agents` is the one exception, and it stays row-level on purpose**
(point 1 of the original brief: "`.has-busy-agents` stays a row-level class
— a tint — but its glyph rung lives in the slot when nothing higher is
active"). A busy session with live subagents needs the busy spinner tinted
violet instead of its default blue; since `agentsBusy` never wins the JS
priority race while `busy` is active, there is no `session-icon--agents-busy`
class to key on in that case — so the tint is a **plain compound selector**,
row tint class + slot rung class, not a priority tie-break:

```css
.session-item.has-busy-agents .session-icon--busy::before { color: #8088ff; }
```

This is the only place a row-level class still appears next to `.session-icon`
in the slot's own visual rules, and `test/session-icon-slot-css-boundary.test.js`
allows it explicitly (it only forbids `:not(`, not row classes generally) —
the boundary is "no priority re-arbitration in CSS", not "no row class may
ever touch the slot's selector".

`test/session-icon-slot-css-boundary.test.js` source-greps `style.css`
(comments stripped first — a couple of them, including the one you're
reading in source, mention `:not()` in prose and would otherwise
false-positive a naive scan) for any selector mentioning `.session-icon` and
asserts none contains `:not(`; `.session-status-dot`/`.remote-host-dot` (the
project header's per-host reachability dot, a different row, untouched by
this migration) are excluded. Mutation-proven: reintroducing
`.session-item.cli-busy:not(.needs-attention) .session-icon--busy { ... }`
turns it red.

`test/dom-sidebar-icon-slot.test.js` pins the slot markup itself (mutation-
proven: swapping the `busy`/`agentsBusy` entries in `ICON_BY_RUNG` turns three
of its DOM tests red, plus the pure-unit tests in `test/session-state.test.js`).
The five tests named in issue #246's step 3b brief
(`dom-sidebar-local-status`, `dom-sidebar-remote-session`,
`dom-sidebar-remote-freshness`, `sidebar-busy-agents-tint`,
`dom-sidebar-remote-activity-pip`) needed no markup changes beyond
`sidebar-busy-agents-tint.test.js`'s CSS-selector regexes (retargeted first
from `.session-status-dot` to `.session-icon`, then from the row-class
`:not()` chains to the flat `session-icon--<rung>` selectors above) — none of
the others assert on the dot/slot element itself, only on row classes and
`.session-status`.

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

Expand Down
7 changes: 6 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,20 @@ const rendererCrossFileGlobals = {
createSessionState: 'readonly',
renderSessionIcon: 'readonly',
// public/session-activity-dom.js — the only file allowed to write
// .cli-busy/.needs-attention/.response-ready/.has-busy-agents.
// .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',
setCliBusy: 'readonly',
setHasBusyAgents: 'readonly',
paintSessionIcon: 'readonly',
// public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md)
setRemoteAttached: 'readonly',
// public/sidebar.js, consumed by session-activity-dom.js's snapshotForLocal
// (see .ai/contexts/session-state.md, "The icon slot (step 3b)")
parentHasActiveSubagent: 'readonly',

// Third-party renderer libs loaded as <script>
morphdom: 'readonly',
Expand Down
6 changes: 3 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -822,9 +822,9 @@ function updateRunningIndicators() {
clearActiveSubagentsFor(id);
}
if (item.dataset.remoteAlias) setRemoteAttached(id, running);
const dot = item.querySelector('.session-status-dot');
if (dot) dot.classList.toggle('running', running);
if (window.ATRACE) window.atrace('class.toggle', id, { el: item.id || null, cls: 'has-running-pty', on: running, dot: !!dot, fn: 'updateRunningIndicators' });
const icon = item.querySelector('.session-icon');
if (icon) icon.classList.toggle('running', running);
if (window.ATRACE) window.atrace('class.toggle', id, { el: item.id || null, cls: 'has-running-pty', on: running, icon: !!icon, fn: 'updateRunningIndicators' });
});
// Update slug group running dots
document.querySelectorAll('.slug-group').forEach(group => {
Expand Down
45 changes: 42 additions & 3 deletions public/session-activity-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function applyActivityClassesToElement(item, sessionId) {
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' });
}

Expand All @@ -49,10 +50,48 @@ function applyActivityClasses(sessionId) {
function applyStateClasses(sessionId, snapshot) {
const item = sessionItemEl(sessionId);
if (!item) return;
const classes = renderSessionIcon(snapshot).classes;
const ready = classes.includes('response-ready');
const busy = classes.includes('cli-busy');
const icon = renderSessionIcon(snapshot);
const ready = icon.classes.includes('response-ready');
const busy = icon.classes.includes('cli-busy');
setResponseReady(item, ready);
setCliBusy(item, busy);
writeIconSlot(item.querySelector('.session-icon'), icon);
if (window.ATRACE) window.atrace('class.apply', sessionId, { el: item.id || null, 'response-ready': ready, 'cli-busy': busy, fn: 'applyStateClasses', kind: snapshot && snapshot.kind });
}

// One icon slot per row, written here and nowhere else — see .ai/contexts/session-state.md
function writeIconSlot(el, icon) {
if (!el || !icon) return;
for (const cls of Array.from(el.classList)) {
if (cls.indexOf('session-icon--') === 0) el.classList.remove(cls);
}
for (const cls of icon.slotClasses) el.classList.add(cls);
el.title = icon.title || '';
el.dataset.glyph = icon.glyph || '';
}

// local-pty snapshot for the icon slot (full priority ladder, unlike computeBusyReadyClasses) — see .ai/contexts/session-state.md
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 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
state.apply({ type: 'liveness', value: 'alive' });
state.apply({ type: 'descriptorStatus', status: sess.status, at: sess.statusUpdatedAt });
}
return state.snapshot();
}

// Paints a local-pty row's icon slot — see .ai/contexts/session-state.md
function paintSessionIcon(el, sessionId, session) {
if (!el) return;
writeIconSlot(el, renderSessionIcon(snapshotForLocal(sessionId, session)));
}
21 changes: 11 additions & 10 deletions public/session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@

const PRIORITY = ['attention', 'responseReady', 'busy', 'agentsBusy', 'waitingForInput', 'idle', 'stale', 'archived'];

// slotClasses use their own session-icon--* namespace, distinct from classes — see .ai/contexts/session-state.md
const ICON_BY_RUNG = {
attention: { classes: ['needs-attention'], glyph: '!', title: 'Needs your attention' },
responseReady: { classes: ['response-ready'], glyph: '●', title: 'Response ready' },
busy: { classes: ['cli-busy'], glyph: '⠋', title: 'Working' },
agentsBusy: { classes: ['has-busy-agents'], glyph: '◆', title: 'Subagents running' },
waitingForInput: { classes: [], glyph: '○', title: 'Waiting for input' },
idle: { classes: [], glyph: '', title: 'Idle' },
stale: { classes: [], glyph: '', title: 'Stale' },
archived: { classes: [], glyph: '', title: 'Archived' },
attention: { classes: ['needs-attention'], slotClasses: ['session-icon--attention'], glyph: '!', title: 'Needs your attention' },
responseReady: { classes: ['response-ready'], slotClasses: ['session-icon--response-ready'], glyph: '●', title: 'Response ready' },
busy: { classes: ['cli-busy'], slotClasses: ['session-icon--busy'], glyph: '⠋', title: 'Working' },
agentsBusy: { classes: ['has-busy-agents'], slotClasses: ['session-icon--agents-busy'], glyph: '◆', title: 'Subagents running' },
waitingForInput: { classes: [], slotClasses: ['session-icon--waiting'], glyph: '○', title: 'Waiting for input' },
idle: { classes: [], slotClasses: ['session-icon--idle'], glyph: '', title: 'Idle' },
stale: { classes: [], slotClasses: ['session-icon--stale'], glyph: '', title: 'Stale' },
archived: { classes: [], slotClasses: ['session-icon--archived'], glyph: '', title: 'Archived' },
};

function createSessionState(kind) {
Expand Down Expand Up @@ -134,11 +135,11 @@ function renderSessionIcon(snapshot) {
for (const rung of PRIORITY) {
if (rungActive(s, rung)) {
const icon = ICON_BY_RUNG[rung];
return { classes: icon.classes.slice(), glyph: icon.glyph, title: icon.title };
return { classes: icon.classes.slice(), slotClasses: icon.slotClasses.slice(), glyph: icon.glyph, title: icon.title };
}
}
const idle = ICON_BY_RUNG.idle;
return { classes: idle.classes.slice(), glyph: idle.glyph, title: idle.title };
return { classes: idle.classes.slice(), slotClasses: idle.slotClasses.slice(), glyph: idle.glyph, title: idle.title };
}

function rungActive(s, rung) {
Expand Down
Loading
Loading