Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge

feat(approvals): let a session watching its own background work keep quiet (#468)

# Conflicts:
#	src/config/cli-registry/stock.ts
This commit is contained in:
Codeman maintainer
2026-09-23 11:32:11 +02:00
35 changed files with 1650 additions and 26 deletions
+22 -2
View File
@@ -4623,6 +4623,11 @@ class CodemanApp {
return {
state,
pill: this._sidebarRichPillLabel(state),
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"). A row that has one went quiet because the agent is waiting for that,
// which is a different thing from waiting for the user — so it rides BESIDE the
// state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0,
since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
};
@@ -4658,6 +4663,17 @@ class CodemanApp {
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
}
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
// The word is duplicated from mobile-overview.js for the same reason the pill labels
// above are: it is one word, and this file must render a complete row even when a
// stale cached mobile-overview.js has arrived without it.
// The visible text is that constant. The pane-derived label appears only in the
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
// already relies on for every untrusted string it puts in an attribute, and where the
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
if (row.watching) {
const title = escapeHtml(`Still running in the background: ${row.watching}`);
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
}
// Both absolute stamps ALSO on the line itself, not only on the two items.
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
// rule), and a tooltip on a `display: none` element has no hover target —
@@ -4695,7 +4711,11 @@ class CodemanApp {
const prev = tab.dataset.tabState;
// The since ANCHOR moves without the state changing (each new turn re-stamps
// lastSubmitAt), so key the compare on both.
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}`;
// Unescaped on purpose, and it still matches the attribute the initial render wrote:
// that one goes through escapeHtml() because it is interpolated into markup, and the
// browser hands the decoded string back through `dataset`. `watching` is the only
// pane-derived value in this signature, which is why it is the only one escaped there.
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`;
if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig;
tab.dataset.tabState = row.state;
@@ -5319,7 +5339,7 @@ class CodemanApp {
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
: '';
// '' whenever the server said nothing about this pane's agent, which covers
+12
View File
@@ -222,6 +222,15 @@ Object.assign(CodemanApp.prototype, {
return;
}
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
// The quiet reason is the only pane-derived string on a card, and it is the one
// an agent could write itself (it prints its own footer row), so it reaches the
// DOM as text and never as markup. The card leaves an empty span for it.
for (const item of items) {
if (!item.acknowledgedReason) continue;
const card = list.querySelector(`[data-approval-id="${CSS.escape(item.id)}"]`);
const slot = card && card.querySelector('.approval-quiet');
if (slot) slot.textContent = 'quiet, ' + item.acknowledgedReason;
}
},
_approvalCardHtml(item) {
@@ -260,6 +269,9 @@ Object.assign(CodemanApp.prototype, {
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
`<span class="approval-age" data-i18n-skip>${age}</span>` +
`</div>` +
// Filled by renderApprovalsDrawer through textContent, never here: see the note
// there. An item a human acknowledged carries no reason and gets no line.
(item.acknowledgedReason ? `<div class="approval-quiet" data-i18n-skip></div>` : '') +
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
`<div class="approval-actions">${actions}</div>` +
+9
View File
@@ -212,6 +212,9 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// What the pane's footer says is still running in the background, straight off
// the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '',
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock below can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
@@ -450,6 +453,12 @@ Object.assign(CodemanApp.prototype, {
// what stops it ellipsizing.
const meta = this._buildHomeSessionsMeta(row);
meta.appendChild(pill);
// Built by the phone overview so both home screens word the badge identically.
// Guarded like every other cross-file call here: a stale cached mobile-overview.js
// must cost the badge, not the rail.
if (row.watching && typeof this._buildWatchingBadge === 'function') {
meta.appendChild(this._buildWatchingBadge(row.watching, 'home-sessions-pill'));
}
item.appendChild(meta);
return item;
+48
View File
@@ -61,6 +61,14 @@ const MOBILE_OVERVIEW_RUN_MODES = [
];
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
/**
* The one word every surface puts on the watching badge, and the tooltip that says
* what the pane actually reported. Both live here so the phone overview, the desktop
* home rail and the rich sidebar rows cannot word the same badge three ways.
*/
const WATCHING_BADGE_TEXT = 'watching';
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
const MOBILE_OVERVIEW_PILL_LABEL = {
needs: 'needs you',
error: 'error',
@@ -185,6 +193,10 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"), straight off the session payload. A row that has one is quiet
// because the agent is waiting for that, not because it is waiting for you.
watching: typeof session.watching === 'string' ? session.watching : '',
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
@@ -713,6 +725,8 @@ Object.assign(CodemanApp.prototype, {
pill.textContent = row.pill;
item.appendChild(pill);
if (row.watching) item.appendChild(this._buildWatchingBadge(row.watching, 'mobile-overview-pill'));
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
@@ -734,6 +748,40 @@ Object.assign(CodemanApp.prototype, {
return item;
},
// ═══════════════════════════════════════════════════════════════
// Watching badge
// ═══════════════════════════════════════════════════════════════
/**
* The badge a session wears while work it started in the background is still
* running: a monitor, a backgrounded shell, a cloud session.
*
* It says one word, and the label the pane itself printed ("1 monitor") rides in
* the tooltip, because the badge shares a row with the state pill on the narrowest
* screen this app renders on. It does NOT replace that pill: an agent can arm a
* monitor and ask the user a question in the same breath, so the row still says
* "needs you" and this says what else is going on.
*
* Shared with the desktop home rail (home-sessions.js), for the same reason
* `_mobileOverviewState` is: one badge, one wording, one place to change it. The
* caller names its own pill class, because each surface styles its pills itself and
* the phone's live inside a media query the desktop never enters.
*/
_buildWatchingBadge(label, baseClass) {
const badge = document.createElement('span');
const base = baseClass || 'mobile-overview-pill';
badge.className = base + ' ' + base + '--watching';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = WATCHING_BADGE_TEXT;
// The label rides in BOTH, because a tooltip is desktop-only: a phone has no hover
// target, and a screen reader gets the one word either way. This is the surface the
// badge was built for first, so "watching" with no way to learn what would be the
// wrong place to save a line.
badge.title = watchingBadgeTitle(label);
badge.setAttribute('aria-label', watchingBadgeTitle(label));
return badge;
},
// ═══════════════════════════════════════════════════════════════
// Age stamps: started / how long in this state
// ═══════════════════════════════════════════════════════════════
+7
View File
@@ -2977,6 +2977,13 @@ html.mobile-init .file-browser-panel {
color: var(--green);
}
/* Accent, and none of the three above: a session watching work it started itself
is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching {
border-color: var(--accent);
color: var(--accent);
}
.mobile-overview-chevron {
flex-shrink: 0;
color: var(--text-muted);
+8
View File
@@ -14,6 +14,14 @@
Object.assign(CodemanApp.prototype, {
// Hooks (Claude Code hook events)
_onHookIdlePrompt(data) {
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
// means the session is watching work it started itself (`acknowledgedReason` reads
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
// own monitor, not for you. The item still exists and still shows in the drawer;
// only the tab alert and the desktop notification are declined. A page that reloads
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
// in seedApprovals (approvals-ui.js).
if (data.acknowledgedReason) return;
// Always track pending hook - alert will show when switching away from session
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'idle_prompt');
+32
View File
@@ -12427,6 +12427,17 @@ kbd {
color: var(--text-dim);
font-size: 0.68rem;
}
/* Why this card is not blinking at anyone: the session is watching work it
started itself. Accent, like the watching badge on a session row, and never
the red or yellow that mean a human is needed. */
.approval-quiet {
margin-bottom: 6px;
color: var(--accent);
font-size: 0.68rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-summary {
color: var(--text);
font-size: 0.76rem;
@@ -16314,6 +16325,17 @@ html[data-tab-orientation='vertical'] .home-sessions {
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* Accent, deliberately none of the three above: a session that is watching something
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
anything, so it must not borrow the red or the yellow that mean it is. This badge
sits BESIDE the state pill rather than replacing it, since an agent can arm a
monitor and ask a question in the same breath. */
.home-sessions-pill--watching {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
/* Row accents: same language as the session tabs and the phone overview — red
means a question is pending, yellow means it wants input, green means work is
happening. Nothing else on this screen may reuse these colors. */
@@ -18376,6 +18398,16 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* Accent, deliberately none of the three above: a watching session is waiting for
work it started itself, not for the user, and the red and yellow here are spoken
for by sessions that ARE waiting for the user. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--watching,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--watching {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
/* Three lines of content per row instead of two, so give them room to breathe
and stop the row actions crowding the pill.