feat(web): mark a session tab whose agent has exited

The tab now reads "exited (137)" beside the session name, drawn from the
`paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM
work, called from the incremental render path — the only path a live session
ever takes, since going from live to exited adds and removes no tab and so
never reaches the full rebuild.

An unknown answer draws nothing. A death tmux could not explain reads "exited"
with no number rather than "exited (0)", so an unexplained death and a clean
exit do not look alike. A signal death reads "exited (signal 9)".

The badge carries `data-i18n-skip`, like the status pills: it is generated
text, `i18n.js` walks inserted content, and a dictionary entry added later
would fight the renderer, whose in-place comparison is against English.

The tab also carries a `tab-agent-exited` class that mutes the status dot. That
dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as
the issue requires, so without this a green or pulsing dot sits beside a badge
saying the agent is gone — the first thing a tester asked about. `status`
itself is untouched, so this is a rendering rule only. The CSS excludes the two
alert classes by hand, following the convention the rich-rail dot rules
document: a dot turning red or yellow because a session is blocked on a human
outranks "the agent exited".

The tab keeps its click behavior. X still closes it, and nothing here closes,
sweeps or restarts anything.

`docs/architecture-invariants.md` gains the mechanism under "Session data and
lifecycle", where every comparable one already lives: what the tri-state means,
the four shapes it is absent for, why the watcher cannot ride the stats
collector, why an absent `#{pane_dead_status}` is not 0, and the three things
that must never happen to an exited pane.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-21 10:52:15 +02:00
co-authored by Claude Opus 5
parent 90a95f562b
commit c67c130caa
4 changed files with 252 additions and 1 deletions
+40
View File
@@ -2433,6 +2433,46 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline-flex;
}
/* The agent inside the pane has exited (Ark0N/Codeman#446). Unlike the badge
above this one carries text the server computed, so it is added and removed
by the renderer rather than toggled by a class. */
.session-tab .tab-exited-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 1px 4px;
margin-left: 4px;
border-radius: 3px;
white-space: nowrap;
background: rgba(248, 113, 113, 0.16);
color: var(--danger);
}
/* An exited agent quiets the status dot, which otherwise reports `status` and
so stays green (or pulses, for a session the exit caught mid-turn) beside a
badge saying the agent is gone. `status` itself is deliberately untouched —
`error` is the PTY-exit breaker's value and makes the browser offer a restart
— so this is a rendering rule only, and it matches `.tab-status.ended`.
⚠ The two alert classes are excluded BY HAND rather than by cascade, the same
way the rich-rail dot rules below do it: a dot turning red or yellow because
a session is blocked on a human outranks "the agent exited", and this
selector is specific enough (0,5,0) to have beaten those (0,3,0) rules
otherwise. The spinner ring is a pseudo-element the skin blocks cannot reach,
so it needs hiding explicitly rather than by unsetting the animation. */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status {
background: var(--text-muted);
opacity: 0.5;
animation: none;
}
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status::after {
display: none;
}
/* ===== Tab action icons: active tab only =================================
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
no width of its own while the children keep the width:0 collapse above).