mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(approvals): make the inbox opt-in (default OFF) and drop em-dashes
Owner decision: every Approvals Inbox UI surface (header bell, drawer, phone overview answer strips, reload seeding) now requires enabling approvalsInboxEnabled in App Settings -> Panels; only an explicit true turns it on. The store, endpoints, and push Approve/Deny actions keep running regardless (the push buttons are already opt-in per subscription). Also replaces em-dashes with plain punctuation across the newly authored comments, docs, and strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
|
||||
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default ON): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event` — at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer — refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit`, which is what makes tab alerts survive reloads; push Approve/Deny actions are answered from `sw.js` directly so they work with no tab open. Surfaces: header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every UI surface is opt-in, though the store/endpoints/push actions run regardless): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny actions are answered from `sw.js` directly so they work with no tab open, setting-independent. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ New module `approvals-ui.js` (@loadorder 11.2, after panels-ui.js), prettier-for
|
||||
- **Desktop**: header bell `btn-approvals` with count badge. Ships default-hidden via marker class `btn-approvals--hidden` (same policy as the attachments button, so `test/mobile-header-buttons-policy.test.ts` excludes it from the default-visible enumeration); JS shows it only while count > 0. Click toggles a drawer of cards: session name + kind, tool/message summary, mono context block, buttons rendered from parsed options (else Approve/Deny), plus Dismiss and Open session. Esc closes; existing z-index layers respected.
|
||||
- **Phone**: header button stays hidden (`mobile.css`); the phone surface is the overview's NEEDS YOU section, whose rows gain inline ✓/✗ buttons for permission items (tap-through to the session remains the row's main action). Toolbar classes/status language rules from the mobile-overview section of CLAUDE.md apply.
|
||||
- **i18n**: new strings registered in i18n.js (en + zh-CN); status words carry `data-i18n-skip` where they would collide (mirroring the overview pills).
|
||||
- **Setting**: `approvalsInboxEnabled`, synced (in `SettingsUpdateSchema`), default ON, resolved from `merged` per the partial-PUT rule. OFF hides the UI surfaces and stops seeding; the store itself keeps running (harmless, and push actions keep working).
|
||||
- **Setting**: `approvalsInboxEnabled`, synced (in `SettingsUpdateSchema`), **default OFF** (owner decision: every UI surface is opt-in, meaning no bell, no drawer, no overview strips, no seeding until enabled in App Settings → Panels). The store and answer endpoints keep running regardless, so the push Approve/Deny actions work either way (they are already opt-in per-subscription via push preferences).
|
||||
|
||||
## Race honesty
|
||||
|
||||
|
||||
+11
-11
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Approvals Inbox — server-side registry of prompts waiting on a human.
|
||||
* @fileoverview Approvals Inbox: server-side registry of prompts waiting on a human.
|
||||
*
|
||||
* One cross-session queue of pending Claude prompts (permission dialogs,
|
||||
* AskUserQuestion/elicitation questions, idle prompts), fed by `/api/hook-event`
|
||||
@@ -48,7 +48,7 @@ export interface ApprovalOption {
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
/** `${sessionId}:${seq}` — stable across re-captures, unique per prompt. */
|
||||
/** `${sessionId}:${seq}`, stable across re-captures, unique per prompt. */
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
@@ -96,7 +96,7 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
* single delayed re-capture picks up the frame the immediate capture missed.
|
||||
*/
|
||||
const RECAPTURE_DELAY_MS = 600;
|
||||
/** Context kept per item — enough for a dialog plus a few lines above it. */
|
||||
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
||||
const MAX_CONTEXT_CHARS = 4000;
|
||||
const MAX_CONTEXT_LINES = 30;
|
||||
const MAX_OPTION_LABEL_CHARS = 120;
|
||||
@@ -144,9 +144,9 @@ export function normalizeCapturedFrame(raw: string | null | undefined): string |
|
||||
* Options must be consecutively numbered from 1 (2..6 of them); description /
|
||||
* wrap / separator lines between options are tolerated up to a small gap
|
||||
* (AskUserQuestion puts a description under every option and a ─ separator
|
||||
* before its "Chat about this" entry — measured against the live dialog). The
|
||||
* before its "Chat about this" entry, measured against the live dialog). The
|
||||
* LAST complete block in the frame wins (dialogs render at the bottom).
|
||||
* Returns undefined when nothing parses — callers then fall back to
|
||||
* Returns undefined when nothing parses; callers then fall back to
|
||||
* approve/deny only, so a mis-parse can never route a digit at a dialog that
|
||||
* does not have it.
|
||||
*/
|
||||
@@ -171,7 +171,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
||||
commit();
|
||||
run = [{ n: 1, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) }];
|
||||
} else if (run.length > 0 && ++gap > 3) {
|
||||
// Too far past the last option for this to still be its description —
|
||||
// Too far past the last option for this to still be its description:
|
||||
// the block is over.
|
||||
commit();
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApprovalInbox {
|
||||
/** Keyed by sessionId — the one-active-item-per-session invariant lives here. */
|
||||
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
||||
private items = new Map<string, ApprovalItem>();
|
||||
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||
@@ -235,7 +235,7 @@ export class ApprovalInbox {
|
||||
* Answer-time guard: re-capture the pane and check the dialog is still on
|
||||
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
|
||||
* frame parsed options: if a fresh capture then parses none, the dialog is
|
||||
* gone (answered in the terminal moments ago) — the item resolves and the
|
||||
* gone (answered in the terminal moments ago), so the item resolves and the
|
||||
* answer must be refused, because the digit would land in whatever now has
|
||||
* focus. Unparseable-from-the-start items stay answerable (approve/deny
|
||||
* only), same risk the terminal user already carries.
|
||||
@@ -250,7 +250,7 @@ export class ApprovalInbox {
|
||||
try {
|
||||
raw = capture();
|
||||
} catch {
|
||||
return true; // capture hiccup — inconclusive, keep the item answerable
|
||||
return true; // capture hiccup: inconclusive, keep the item answerable
|
||||
}
|
||||
const context = normalizeCapturedFrame(raw);
|
||||
if (!context) return true;
|
||||
@@ -316,7 +316,7 @@ export class ApprovalInbox {
|
||||
|
||||
/**
|
||||
* Resolve a session's pending item, if any (stop hook, exit, ...). `kinds`
|
||||
* restricts which item kinds the signal may clear — the heuristic `working`
|
||||
* restricts which item kinds the signal may clear: the heuristic `working`
|
||||
* transition passes `['idle']` so a mid-turn flap cannot false-clear a
|
||||
* pending permission/question dialog.
|
||||
*/
|
||||
@@ -347,7 +347,7 @@ export class ApprovalInbox {
|
||||
const context = normalizeCapturedFrame(raw);
|
||||
if (!context) return;
|
||||
item.context = context;
|
||||
// Idle prompts are not dialogs — never offer digit answers for them.
|
||||
// Idle prompts are not dialogs; never offer digit answers for them.
|
||||
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Approvals Inbox UI — cross-session queue of prompts waiting on a human.
|
||||
* @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human.
|
||||
*
|
||||
* Renders the header bell (count badge, shown only while items are pending) and
|
||||
* the right-side drawer of approval cards, seeds pending items from
|
||||
* `GET /api/approvals` on init/reconnect (so tab alerts survive a reload), and
|
||||
* answers items in place via `POST /api/approvals/:id/answer`. Cards render
|
||||
* Everything here is gated on the OPT-IN `approvalsInboxEnabled` setting
|
||||
* (synced, default OFF): with it off, no bell, no drawer, no overview strips,
|
||||
* no seeding. When on, the header bell renders only while items are pending
|
||||
* (count badge), opening a right-side drawer of approval cards; pending items
|
||||
* are seeded from `GET /api/approvals` on init/reconnect (so tab alerts
|
||||
* survive a reload) and answered in place via `POST /api/approvals/:id/answer`. Cards render
|
||||
* buttons from the server-parsed dialog options; without parsed options they
|
||||
* fall back to Approve/Deny (permission/question) or a text prompt (idle).
|
||||
* Backend: src/web/approval-inbox.ts, design: docs/approvals-inbox-plan.md.
|
||||
@@ -13,7 +15,7 @@
|
||||
* @dependency app.js (CodemanApp class, this.approvals, setPendingHook/clearPendingHooks, selectSession)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
|
||||
* @loadorder 11.6 of 17 — after ultracode-panel.js, before admin-ui.js
|
||||
* @loadorder 11.6 of 17, after ultracode-panel.js, before admin-ui.js
|
||||
*/
|
||||
|
||||
/** Map an approval kind to the pendingHooks entry that drives tab alerts. */
|
||||
@@ -22,14 +24,14 @@ function approvalKindToHook(kind) {
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Synced setting, default ON (only an explicit false disables). */
|
||||
/** Synced setting, default OFF, opt-in via App Settings → Panels. */
|
||||
approvalsInboxEnabled() {
|
||||
return this.loadAppSettingsFromStorage().approvalsInboxEnabled !== false;
|
||||
return this.loadAppSettingsFromStorage().approvalsInboxEnabled === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed pending approvals from the server. Called from handleInit, i.e. on
|
||||
* every page load AND SSE reconnect — this is what makes pending alerts
|
||||
* every page load AND SSE reconnect; this is what makes pending alerts
|
||||
* survive a reload (pre-inbox they lived only in SSE-transient memory).
|
||||
*/
|
||||
async seedApprovals() {
|
||||
@@ -51,7 +53,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
_onApprovalPending(item) {
|
||||
if (!item || !item.id) return;
|
||||
if (!this.approvals) this.approvals = new Map();
|
||||
// One active item per session (server invariant) — drop any stale sibling.
|
||||
// One active item per session (server invariant): drop any stale sibling.
|
||||
for (const [id, existing] of this.approvals) {
|
||||
if (existing.sessionId === item.sessionId) this.approvals.delete(id);
|
||||
}
|
||||
@@ -88,7 +90,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast(action === 'deny' ? 'Denied' : 'Answer sent', 'success');
|
||||
} else {
|
||||
// 404/409 = resolved elsewhere or the dialog left the screen; refresh truth.
|
||||
this.showToast('Could not answer — prompt may already be resolved', 'warning');
|
||||
this.showToast('Could not answer, the prompt may already be resolved', 'warning');
|
||||
this.seedApprovals();
|
||||
}
|
||||
},
|
||||
@@ -104,7 +106,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
if (data) this.showToast('Prompt sent', 'success');
|
||||
else {
|
||||
this.showToast('Could not send — session may be busy', 'warning');
|
||||
this.showToast('Could not send, the session may be busy', 'warning');
|
||||
this.seedApprovals();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2509,7 +2509,7 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Approvals Inbox answer strip: sits under a NEEDS YOU row (sibling of the
|
||||
row <button>, see _buildMobileOverviewApprovalStrip). Buttons inherit no
|
||||
toolbar styling on purpose — they are one-tap dialog answers, not runs. */
|
||||
toolbar styling on purpose; they are one-tap dialog answers, not runs. */
|
||||
.mobile-overview-row-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onHookElicitationComplete(data) {
|
||||
// Question answered in the terminal — clear the action alert without
|
||||
// Question answered in the terminal: clear the action alert without
|
||||
// waiting for `stop` (the turn may keep running for a long time).
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||
@@ -172,7 +172,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (event.data?.type === 'notification-click') {
|
||||
const { sessionId, action, approvalId } = event.data;
|
||||
if (action) {
|
||||
// Approve/Deny action buttons on a push — answer via the
|
||||
// Approve/Deny action buttons on a push: answer via the
|
||||
// Approvals Inbox instead of just focusing the session.
|
||||
this.handleNotificationAction?.(action, approvalId, sessionId);
|
||||
} else if (sessionId && this.sessions.has(sessionId)) {
|
||||
@@ -342,8 +342,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
// Approvals Inbox: synced, default ON (only an explicit false disables).
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled !== false;
|
||||
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
|
||||
@@ -10643,7 +10643,7 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Approvals" header bell — appears ONLY while prompts are pending (JS toggles
|
||||
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
|
||||
the marker class on count changes), so it ships hidden and stays out of the
|
||||
default header. Same marker pattern as the attachments button. */
|
||||
.btn-approvals {
|
||||
@@ -10672,7 +10672,7 @@ kbd {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Approvals Inbox drawer — same shell as the attachment history drawer. */
|
||||
/* Approvals Inbox drawer: same shell as the attachment history drawer. */
|
||||
.approvals-drawer {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
|
||||
@@ -159,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
body: JSON.stringify({ action }),
|
||||
}).then((res) => {
|
||||
if (res && res.ok) return undefined;
|
||||
// 401/404/409: let the human see the state — fall back to a tab.
|
||||
// 401/404/409: let the human see the state by falling back to a tab.
|
||||
return openOrFocus(sessionId, action, approvalId, targetUrl);
|
||||
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
|
||||
);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* The cross-session queue of prompts waiting on a human (see
|
||||
* web/approval-inbox.ts, docs/approvals-inbox-plan.md):
|
||||
* - `GET /api/approvals` — pending items, ownership-scoped in multi-user mode
|
||||
* - `POST /api/approvals/:id/answer` — answer in place by sending the
|
||||
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode
|
||||
* - `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/:id/dismiss`: drop the item without keystrokes
|
||||
*
|
||||
* 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
|
||||
@@ -24,8 +24,8 @@ import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/**
|
||||
* Keystrokes for an answer, or an error string. Menu answers are a single digit
|
||||
* or Esc — dialogs react to the keypress itself, so no Enter is ever sent for
|
||||
* them. Free text is allowed only for idle prompts (there IS no dialog; the
|
||||
* or Esc (dialogs react to the keypress itself, so no Enter is ever sent for
|
||||
* them). Free text is allowed only for idle prompts (there IS no dialog; the
|
||||
* text lands in the composer and `\r` submits it, per the CLAUDE.md input
|
||||
* discipline). `option` digits must match a PARSED option so a blind digit can
|
||||
* never be routed at a dialog we could not read.
|
||||
@@ -82,7 +82,7 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Covers unknown, already-answered, superseded and expired ids alike.
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
|
||||
}
|
||||
// Throws 404 (not 403) for sessions the caller does not own — same
|
||||
// Throws 404 (not 403) for sessions the caller does not own, same
|
||||
// no-existence-leak rule as every other session route.
|
||||
const session = findSessionOrFail(ctx, item.sessionId, req);
|
||||
if (!hooksAvailableForMode(session.mode)) {
|
||||
|
||||
+5
-4
@@ -680,7 +680,7 @@ export const HookEventSchema = z.object({
|
||||
/**
|
||||
* Body of POST /api/approvals/:id/answer (Approvals Inbox).
|
||||
* `option` digits are additionally validated against the item's PARSED options
|
||||
* in the route — the schema alone must not authorize blind digit-poking.
|
||||
* in the route; the schema alone must not authorize blind digit-poking.
|
||||
*/
|
||||
export const ApprovalAnswerSchema = z
|
||||
.object({
|
||||
@@ -791,9 +791,10 @@ export const SettingsUpdateSchema = z
|
||||
*/
|
||||
agentSkillEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Approvals Inbox UI (header badge + drawer, phone overview answer buttons).
|
||||
* SYNCED, default ON: the surfaces only appear while a prompt is pending.
|
||||
* The server-side store runs regardless (push actions keep working).
|
||||
* Approvals Inbox UI (header bell + drawer, phone overview answer buttons).
|
||||
* SYNCED, default OFF (opt-in): even with items pending, no surface renders
|
||||
* until this is enabled. The server-side store and answer endpoints run
|
||||
* regardless, so push Approve/Deny actions keep working either way.
|
||||
*/
|
||||
approvalsInboxEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
|
||||
+1
-1
@@ -2152,7 +2152,7 @@ export class WebServer extends EventEmitter {
|
||||
body,
|
||||
tag: `codeman-${event}-${sessionId}`,
|
||||
sessionId,
|
||||
// Approvals Inbox item id — lets sw.js answer an Approve/Deny action
|
||||
// Approvals Inbox item id: lets sw.js answer an Approve/Deny action
|
||||
// click directly (POST /api/approvals/:id/answer) with no tab open.
|
||||
approvalId: typeof data.approvalId === 'string' ? data.approvalId : undefined,
|
||||
urgency: template.urgency,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Approvals Inbox store unit tests (src/web/approval-inbox.ts).
|
||||
*
|
||||
* Pure in-memory registry — no ports, no server. Constructs its own
|
||||
* Pure in-memory registry: no ports, no server. Constructs its own
|
||||
* ApprovalInbox instances (never the process singleton) so tests cannot
|
||||
* leak state into the route tests that share the module.
|
||||
*/
|
||||
@@ -118,7 +118,7 @@ describe('normalizeCapturedFrame', () => {
|
||||
});
|
||||
|
||||
it('converts absolute row repaints (formatPaneSnapshot frames) into lines', () => {
|
||||
// The visible tmux capture carries NO newlines — every row is painted at
|
||||
// The visible tmux capture carries NO newlines; every row is painted at
|
||||
// `ESC[<row>;1H`. Measured against a live dialog frame.
|
||||
const raw = '\x1b[12;1H Which color do you prefer?\x1b[13;1H❯ 1. Red\x1b[14;1H Prefer red\x1b[15;1H 2. Blue';
|
||||
const out = normalizeCapturedFrame(raw)!;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject()
|
||||
* — no live port. The hook-event route is registered alongside so items are
|
||||
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject(),
|
||||
* no live port. The hook-event route is registered alongside so items are
|
||||
* created through the REAL ingestion path (sanitize → notePrompt with the
|
||||
* terminal-buffer capture fallback), not by poking the store directly.
|
||||
*
|
||||
* The routes read the process-wide `approvalInbox` singleton, so every test
|
||||
* drains it in afterEach — a leaked pending item would bleed into the next test.
|
||||
* drains it in afterEach; a leaked pending item would bleed into the next test.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
@@ -203,7 +203,7 @@ describe('approval routes', () => {
|
||||
it('refuses with 409 when the dialog left the screen since capture', async () => {
|
||||
await postHook(harness, 'permission_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
// The dialog scrolled away — the re-capture at answer time must refuse.
|
||||
// The dialog scrolled away, so the re-capture at answer time must refuse.
|
||||
session.terminalBuffer = 'claude is off doing something else now';
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
@@ -301,7 +301,7 @@ describe('approval routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes — multi-user scoping', () => {
|
||||
describe('approval routes: multi-user scoping', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user