A session's mark says what its agent is doing #126
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/attention-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Stacked on #125. That PR lands
attention_stateonSessionDTOand renders none of it; this is the other half. The branch ismain+ #125 + two UI commits — once #125 merges, the diff here is just those.The mark
The status dot, extended rather than replaced:
workingneeds-youidleendednullEvery state keeps the 8px box (
box-sizingis global), so a row never shifts as its state changes. Every mark carries a tooltip, because the vocabulary is new and the mark is small.Motion is a claim about now, so only the two live states move — and they move differently: rotation reads as busy, a ping reads as calling you. Spinning a blocked agent would say the opposite of what is true. Under
prefers-reduced-motionthe rotating ring closes rather than freezing mid-gap, since a stopped spinner still reads as "working".Three inputs, one entry point
workspaces.markFor(workerId, session)is the only way any surface derives a mark, because the store is the only thing holding all three inputs: the roster row, its health probe, and whether the workspace is running at all.That third one is not academic.
stopWorkerwritesworkers.statusand never touches a session row, so every session of a deliberately stopped workspace still readsstatus: 'running'while its probe reports the container down — derived without it, a workspace stopped on purpose paints its whole rail capsule red.Within a running workspace: the probe owns whether claude is alive, attention owns what a live agent is doing. So an absent claude stays a fault except when the row reported
ended—SessionEndfires forlogoutandprompt_input_exitand never for a crash. That row gets a grey ring and "restart the agent from the ⋯ menu" instead of a red "no claude running in <worktree>". A bug fix rides along: today the sidebar reports a session you closed on purpose as a crash. The converse holds too — a live claude makes anendedreport stale, so it is ignored rather than believed.The other surfaces
needsYou(w)already paints an amber pip for a provisioning error or a degraded boot, and a blocked agent is a third input to a predicate that exists. Segments render the same marks.stopped.Verification
Eleven mutations, eleven correct failures — including adding motion to a state that should be still, so the guards bite in both directions. A generated parity suite checks the mark against the reconstructed pre-change
dotClassacross every health shape the prober can emit, which is what makes "null renders exactly as before" a claim rather than a hope.Driven in a real browser on every surface, including
prefers-reduced-motiontoggled live (rotation → closed ring, ping → fixed halo).1300 tests,
tsc0 errors, eslint clean.Review pass
The second commit is a four-agent review (simplification, reuse, an adversarial audit of the comments' factual claims, and altitude). It found two regressions against pre-branch behaviour, both sitting under a comment of mine asserting the opposite:
health.claudeRunningwhile the fault line beside it readshealth.health. The field is three-valued, and its'unknown'arm shipsclaudeRunning: falseas a placeholder, so the boolean turned "we could not look" into "claude is dead" — a red dot with no explanation under it. The other direction gave a healthy green mark beside a red "tmux window not present".session.statusdoes not actually provide.It also caught the hover card rendering one session's state three ways, two of which could contradict on screen (
agent stoppeddirectly abovedoing agent exited), and a pile of simplifications:margin-topmoved off the shared rule that three of four call sites were cancelling, a dead.status-dot.exited, an unreachable guard, two hand-rolled copies of an accessor extracted in the same commit, ~15 lines of duplicated rationale, and a test the compiler already made redundant.Not taken: feeding blocked sessions into the command palette's "Needs you" group. Real gap, but a feature rather than a repair.
Design
Settled against a live mock: the spinner idiom picked from five animated candidates,
needs-youfrom four chip-free treatments (ping / steady ring / amber name / row tint), and the quiet states from three (rings / rings + a plain age / nothing).The per-session toggle could only ever turn agent teams ON. buildLaunchCommand prepended CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 when enabled and emitted nothing when disabled — but claude-code merges every settings scope's `env` over process.env, and this key is in the unconditional write allowlist, so an entry in the fleet-shared ~/.claude/settings.json overwrites the launch prefix. The operator's shared settings.json sets it to "1", so every session ran with agent teams on while the UI showed the toggle off. Measured against 2.1.260, shared settings.json = "1": CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0 claude ... -> effective value 1 claude --settings '{"env":{"...":"0"}}' ... -> effective value 0 --settings is `flagSettings`, which merges after `userSettings`, so it wins in both directions; the merge is per key, so the operator's other env entries, statusLine, permissions and plugins are untouched. State the disabled case explicitly rather than implying it by absence, and pin that with a regression test — absence was the bug. Also refresh worker/README.md, which still documented `--worktree` and one session per container, both superseded by Nexus-owned worktrees (fact #9) and the multi-session model (fact #7).Ran the spike against 2.1.260 in a live worker with a throwaway --plugin-dir plugin — no image rebuild needed. All four questions answered; two changed the design. Confirmed: plugin hooks fire with no approval prompt; NEXUS_SESSION_ID and NEXUS_URL both reach the handler; ${CLAUDE_PLUGIN_ROOT} resolves; async:true does not delay a turn. Changed the design: - Async hooks are KILLED at process exit and racy near it (a 12s async hook logged its start and never its finish; the same script standalone finished normally). SessionEnd fires at exit, so it is now the one synchronous subscription. Everything else stays async — an interactive session does not exit at Stop, so the race does not arise there. - The SessionEnd payload field is `reason`, not the documented `end_reason`: {"hook_event_name":"SessionEnd","reason":"other"}. Matchers are unaffected (logout|prompt_input_exit|other matched, clear|resume did not, confirming the split), but a handler parsing end_reason would read null forever. Also records the remaining unmeasured case: Notification types are verified by string against the binary but never fired in a non-interactive run.