Skip to content

(session-state): one domain module, one icon slot, enforced single writer across local/remote/subagent sources #246

Description

@devsuitup

Why

Session indicators are computed by different code paths depending on the source: PTY-attached local sessions get .cli-busy / .needs-attention / .response-ready from OSC parsing; remote sessions get an activity pip from the watch channel (#241, #242) plus a state + age text; local sessions launched outside Switchboard get nothing; subagents light their parent only through activeSessions. Each new source (a second host, OpenCode, Codex) would add another path.

public/session-activity.js:27 declares itself the single writer of .cli-busy / .response-ready. That rule holds by discipline only: the first version of the remote spinner alignment bypassed setActivity() from two files and neither lint nor tests went red. Nothing in the codebase makes a violation fail.

A full audit with file:line for every indicator, the divergences and the proposed shapes exists as a local note (.work-files/switchboard/audit-indicateurs-2026-09-10.md); its substance is below.

What

A pure domain module for session state, one projection, and enforcement.

Domainsession-state.js, dual-loadable like public/restore-plan.js (#239) and public/subagent-timing.js: no DOM, no IPC, no electron, loadable under node:test as is.

createSessionState(kind)  // local-pty | local-transcript | remote-ssh | later: opencode, codex
  .apply(event)
  .snapshot() -> { kind, liveness, busy, waitingForInput, attention, agentsBusy,
                   lastActivityAt, lastActivitySource, label, labelConfidence,
                   attachable, archived, stale }

Invariants live here, not in CSS :not() chains: busy / waitingForInput / attention exclusivity, and the priority order (attention > response-ready > busy > agentsBusy > waitingForInput > idle+age > stale > archived).

Ports — the events a source may push. What each adapter can honestly emit:

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

Only a PTY can tell "thinking" from "waiting for input". An adapter without one must never claim waitingForInput; it produces busy: unknown plus lastActivityAt.

ProjectionrenderSessionIcon(snapshot) returns classes, glyph and title for one icon slot per row. It absorbs today's .session-status-dot, the busy spinner, .has-busy-agents, and the remote state + age text. .remote-badge (identity) and .remote-host-dot (per-project reachability) legitimately stay separate.

Enforcement — a violation fails, it is not discouraged:

  • ESLint no-restricted-syntax forbidding classList.add/toggle with cli-busy / needs-attention / response-ready / has-busy-agents outside the projection file (no no-restricted-* rule exists in eslint.config.js today).
  • A boundary test asserting the domain module references neither document, window nor electron (same shape as test/main-ctx-db-wiring.test.js).
  • DOM tests go through the real renderSessionIcon(), never a replica (Five test files exercise a hand-written replica, not the production module #171).

Migration order (least churn first)

  1. Split public/session-activity.js into state (the Maps/Sets + setActivity) and DOM (applyActivityClasses). This is the seam that does not exist yet.
  2. Introduce session-state.js + renderSessionIcon() behind the existing applyActivityClasses call, local-pty only, with the ESLint rule active.
  3. Move remote-activity-ui.js and remote-activity.js (already a pure factory) onto the remote-ssh adapter.
  4. Add the local-transcript adapter: transcript growth via the existing ~/.claude/projects watcher, for sessions launched outside Switchboard.
  5. Subagent attribution through the adapters (separate issue).

Not now

ssh transport (remote-transport.js, remote-mirror.js) — already isolated. Grid vs sidebar unification — separate, deliberate parallel implementations. The non-namespaced session_cache.sessionId primary key.

Cost, stated

main.js is ~2600 lines and mixes transport and decision (OSC parsing 1910-1974, annotateRemoteAttachable 520-545); the renderer is classic scripts sharing a global scope. The dual-load pattern already exists, so the new module is not a new cost; the split of session-activity.js and a unified session notion in main.js are.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions