chore: version packages

Persist the 'I checked it' state of yellow idle tab alerts across reloads and devices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-17 00:46:03 +02:00
parent fa8ebe0068
commit f4ba4d2cb1
12 changed files with 232 additions and 14 deletions
+29
View File
@@ -19,6 +19,8 @@
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
* item stays pending, it just stops arming the tab alert on every client.
*
* @dependencies utils (stripAnsi)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
@@ -61,6 +63,14 @@ export interface ApprovalItem {
cwd?: string;
/** ANSI-stripped tail of the visible pane frame at capture time. */
context?: string;
/**
* Set when a human looked at the session (the web UI selecting its tab). The
* item stays PENDING and answerable, only its tab alert is spent: clients
* skip re-arming the alert for an acknowledged item when they seed from
* `GET /api/approvals`, which is what makes "I checked it" survive a reload
* and reach the user's other devices. See `acknowledge()`.
*/
acknowledgedAt?: number;
/**
* Present only when the frame parsed confidently. Gates which digits the
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
@@ -306,6 +316,25 @@ export class ApprovalInbox {
this.onPending?.(item);
}
/**
* Mark a session's pending item as SEEN by a human, and return it (undefined
* when there is nothing to acknowledge or it is already acknowledged). The
* item is NOT resolved: an idle prompt a human glanced at is still unanswered,
* so it stays in the inbox, stays answerable, and stays available as Read My
* Mind context. Only the tab alert it armed is spent.
*
* ⚠️ `kinds` defaults to `['idle']` and callers must keep it that narrow:
* looking at a permission/question dialog does not answer it, so the red
* "needs you" alert has to survive being viewed.
*/
acknowledge(sessionId: string, kinds: ApprovalKind[] = ['idle']): ApprovalItem | undefined {
const item = this.getForSession(sessionId);
if (!item || !kinds.includes(item.kind) || item.acknowledgedAt) return undefined;
item.acknowledgedAt = Date.now();
if (!this.stopped) this.onUpdated?.(item);
return item;
}
/** Remove an item without keystrokes (user chose Dismiss). */
dismiss(id: string): boolean {
const item = this.getById(id);
+31 -3
View File
@@ -845,6 +845,24 @@ class CodemanApp {
this.updateTabAlertFromHooks(sessionId);
}
/**
* "I looked at this session": spend its pending IDLE tab alert (the yellow
* one), locally AND server-side. The clear used to live only in this tab's
* memory, so `seedApprovals()` re-armed it from `GET /api/approvals` on the
* next reload (a tab you had already checked went yellow again), and the
* user's other devices never heard about it. The server marks the approval
* item acknowledged (it stays pending and answerable) and broadcasts
* `approval:updated`, which is what clears the alert everywhere else.
*
* ⚠️ Idle only: action alerts (permission/question) mean an unanswered dialog
* is on screen, and looking at one does not answer it.
*/
markIdleAlertSeen(sessionId) {
if (!this.pendingHooks.get(sessionId)?.has('idle_prompt')) return;
this.clearPendingHooks(sessionId, 'idle_prompt');
this.acknowledgeIdleApprovalOnView?.(sessionId);
}
updateTabAlertFromHooks(sessionId) {
const hooks = this.pendingHooks.get(sessionId);
if (!hooks || hooks.size === 0) {
@@ -5144,7 +5162,15 @@ class CodemanApp {
if (this._raiseDetached(sessionId)) return;
}
const forceReload = options?.forceReload === true;
if (this.activeSessionId === sessionId && !forceReload) return;
if (this.activeSessionId === sessionId && !forceReload) {
// Clicking the tab you are already on is still "I checked it". The alert
// can be armed on the ACTIVE tab (a live idle_prompt fires regardless of
// which tab is showing, and so does the reload seed), and every other
// clear path runs on the switch this early return skips, leaving a
// yellow tab that no click could clear.
this.markIdleAlertSeen(sessionId);
return;
}
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
@@ -5200,8 +5226,10 @@ class CodemanApp {
// switch when that option is on. Transform/opacity/clip-path only, xterm's
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// Clear idle hooks on view, but keep action hooks until user interacts.
// Also acknowledged server-side, so the yellow does not come back on the
// next reload and the user's other devices clear it too.
this.markIdleAlertSeen(sessionId);
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
// Handheld: the session drawer overlays the terminal, so slide it away now
+30 -1
View File
@@ -50,6 +50,11 @@ Object.assign(CodemanApp.prototype, {
const inboxOn = this.approvalsInboxEnabled();
for (const item of (data && data.approvals) || []) {
if (inboxOn) this.approvals.set(item.id, item);
// ⚠ Skip items a human already looked at (`acknowledgedAt`, set by
// markIdleAlertSeen → POST .../viewed). Re-arming those is exactly the
// bug this flag exists for: clicking a yellow tab cleared the alert in
// this tab's memory only, so the next reload seeded it right back.
if (item.acknowledgedAt) continue;
// Re-arm the tab alert state machine (idempotent set-add).
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
}
@@ -70,7 +75,14 @@ Object.assign(CodemanApp.prototype, {
},
_onApprovalUpdated(item) {
if (!item || !item.id || !this.approvals?.has(item.id)) return;
if (!item || !item.id) return;
// Acknowledged elsewhere (this user opened the session on another device):
// spend the tab alert UNCONDITIONALLY, for the same reason
// _onApprovalResolved does: with the inbox setting OFF the item was never
// stored in `this.approvals`, yet seedApprovals armed its alert, so gating
// this on a map hit would strand a yellow tab on every other device.
if (item.acknowledgedAt) this.clearPendingHooks(item.sessionId, approvalKindToHook(item.kind));
if (!this.approvals?.has(item.id)) return;
this.approvals.set(item.id, item);
this.renderApprovals();
},
@@ -89,6 +101,23 @@ Object.assign(CodemanApp.prototype, {
// ─── Actions ─────────────────────────────────────────────────
/**
* Tell the server the session's pending IDLE prompt has been looked at, so
* the yellow tab alert stays gone: `seedApprovals()` skips acknowledged
* items on the next reload, and the resulting `approval:updated` broadcast
* clears the alert on the user's other devices. Called by markIdleAlertSeen
* (app.js), which owns the local half of the clear.
*
* Fire-and-forget: the alert is already down locally, `_apiJson` swallows
* failures, and the worst case of a lost POST is today's behavior (yellow
* returns after a reload). Runs regardless of `approvalsInboxEnabled`,
* since the tab alert predates the inbox and is not gated on it.
*/
acknowledgeIdleApprovalOnView(sessionId) {
if (!sessionId) return;
this._apiJson(`/api/approvals/session/${encodeURIComponent(sessionId)}/viewed`, { method: 'POST' });
},
async answerApproval(id, action, option) {
const body = option !== undefined ? { action, option } : { action };
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
+20
View File
@@ -7,6 +7,8 @@
* - `POST /api/approvals/:id/answer`: answer in place by sending the
* corresponding keystrokes to the session (digit / Esc / idle-prompt text)
* - `POST /api/approvals/:id/dismiss`: drop the item without keystrokes
* - `POST /api/approvals/session/:sessionId/viewed`: mark the session's pending
* IDLE prompt as seen (tab alert spent, item still pending)
*
* Normal authed API surface (NOT the localhost hook-secret bypass). Answering
* is take-then-write: the item is removed BEFORE keystrokes go out so a
@@ -113,6 +115,24 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
return { success: true, data: { id: item.id, sessionId: item.sessionId, action: answer.action } };
});
/**
* "A human is looking at this session": acknowledge its pending IDLE prompt.
* The yellow tab alert used to be cleared in the browser's memory only, so
* `GET /api/approvals` re-armed it on the next reload (a tab you had already
* checked went yellow again) and the user's other devices never heard about
* it at all. The item is NOT resolved, only marked seen; the
* `approval:updated` broadcast is what clears the alert everywhere else.
*
* ⚠️ Idle only, by construction (`acknowledge()` defaults to `['idle']`):
* viewing a permission/question dialog does not answer it, so the red alert
* must survive being viewed.
*/
app.post<{ Params: { sessionId: string } }>('/api/approvals/session/:sessionId/viewed', async (req) => {
const session = findSessionOrFail(ctx, req.params.sessionId, req);
const item = approvalInbox.acknowledge(session.id);
return { success: true, data: { sessionId: session.id, acknowledged: item?.id ?? null } };
});
app.post<{ Params: { id: string } }>('/api/approvals/:id/dismiss', async (req) => {
const item = approvalInbox.getById(req.params.id);
if (!item) {