From 6fc8e96c986de9b4e41b34a9bb4211055b7b4b5e Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Fri, 3 Jul 2026 01:23:10 +0100 Subject: [PATCH 1/5] =?UTF-8?q?docs(work):=20out-of-credits=20UX=20?= =?UTF-8?q?=E2=80=94=20unmissable=20state=20+=20fail-early=20on=20credit-g?= =?UTF-8?q?ated=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working doc from the credits-exhaustion triage (a halted autumn org → deep 402 + runtime.fallback loop, surfaced as a generic runtime error). Two gaps: the halted state isn't unmissable at the moment of action, and credit-gated ops fail late + opaque instead of early + clear. Frames it as gap-fill on what already exists (the HaltBanner in app-shell, the edge halt-gates on create/wake, the runtime insufficient_credits signal, and Slice 1 of billing-usage-and-topup.md) — not a rebuild. Two parts: - A: make the halted state unmissable (sticky/stronger banner + surface it at the composer/new-session/run controls). - B: fail credit-gated ops early — preflight the halt on the v3 turn/session path (mirror the edge gates), classify 402-credits as terminal to kill the fallback loop, and gate the UI controls when halted. Includes a which-changes-where table, the flow, and its relationship to billing-usage-and-topup.md (refines Slice 1; defers usage-breakdown/monthly-cap/ attribution to that doc). Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/work/out-of-credits-ux.md | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .agents/work/out-of-credits-ux.md diff --git a/.agents/work/out-of-credits-ux.md b/.agents/work/out-of-credits-ux.md new file mode 100644 index 00000000..24468541 --- /dev/null +++ b/.agents/work/out-of-credits-ux.md @@ -0,0 +1,72 @@ +# Out-of-credits UX — unmissable state + fail-early on credit-gated ops + +Status: active (draft) +Last updated: 2026-07-03 +Owns: the out-of-credits FAILURE experience — proactive clarity + preflight early-fail. Refines Slice 1 of [`billing-usage-and-topup.md`](./billing-usage-and-topup.md) (which owns the broader billing/usage/top-up surface). +Supersedes: — +Implementation: `web/src/components/app-shell.tsx` (+ session views) · `cloudflare-workers/api-edge/src/index.ts` · sessions-api runtime provision/accept + fallback classifier +Public docs: — + +## Trigger + +2026-07-03: a halted autumn org (free plan, balance ≤ 0, `is_halted=1`). Every turn failed at sandbox provision with a deep `402 {"error":"credits exhausted — top up to resume"}`, retried ~6× via `runtime.fallback`, and surfaced as a generic runtime error. Two problems flagged: + +- **Not unmissable.** The halted state isn't unavoidably in the user's face at the moment they act. +- **Fails late + opaque.** An op we KNOW can't succeed (org halted / balance ≤ 0) still runs deep into provisioning before dying — and then loops on a terminal 402. + +## What already exists (do NOT rebuild) + +- **`HaltBanner`** (`web/src/components/app-shell.tsx:247`): polls `getAutumnBilling` (`isHalted`), renders a top-of-content "you're out of prepaid credits — top up" banner on every page except `/billing`. Autumn orgs only (legacy 404s → no banner). +- **Edge halt-gates** on some paths: session create (`index.ts:~443`, with a self-heal re-check) and wake (`~859`) return early when `is_halted`. +- **Runtime credit signal:** on a 402 the runtime posts `error.runtime {code:"insufficient_credits"}` + a user message, classified `credits_exhausted`. +- **Billing top-up UI** (`web/src/pages/Billing.tsx`) + billing doc **Slice 1** (reactive exhaustion CTA, low-balance banner, fast un-halt). + +This is **gap-fill on top of those**, in two parts. + +## Part A — Make "out of credits" unmissable, where the user acts + +The banner exists but is a thin top strip and is passive (it doesn't touch the control the user is about to click). + +1. **Raise prominence** of the *halted* state (distinct from Slice 1b's low-balance banner): stronger treatment (icon + emphasis/destructive color, not the muted `pending` style) and **sticky** at the top of the content area so it stays visible while scrolling. Keep the top-up CTA. +2. **Cover the moment of action** (see Part B3): show the halted state inline at the composer / "New session" / "Run", not only as a page-top strip — a user who never scrolls up still can't miss it. +3. **Verify it's live:** the banner polls every 30s; after Part B's fast un-halt (Slice 1c) confirm `isHalted` flips within the poll window post-top-up. + +No new data needed — `getAutumnBilling.isHalted` / `creditsRemainingCents` already exist. + +## Part B — Fail credit-gated ops EARLY and CLEARLY + +Principle: **if we know an operation can't succeed, refuse it at the boundary with a specific message — never let it run deep and die generic.** + +1. **Preflight the halt on the v3 turn/session path.** The failing trace shows the turn reaching `sandbox.ensure` → deep `402` with no halt check first (unlike the edge create/wake gates). Add a preflight halt check on session-create / turn-accept / provision so a halted org yields an immediate typed terminal `insufficient_credits` — no sandbox attempt. Mirror the existing edge gates (`index.ts:443/859`). +2. **Classify 402-credits as terminal — kill the fallback loop.** `runtime.fallback` retried the 402 ~6×; that last-known-good-runtime fallback is for **missing snapshots** (infra), not billing. A `402 credits exhausted` must be terminal-non-retriable → stop after one and emit the clean `insufficient_credits` signal. Cheap, and it de-noises telemetry (these log as `provision` errors today). +3. **Gate the UI controls.** When `isHalted`, disable/replace the credit-gated affordances — "New session", the composer's send, "Run" — with an inline "Out of credits — top up to resume →" (reusing the halted state + Slice 1a's CTA). The user shouldn't be able to fire an op we know will fail; if they do (race / API user), the B1 preflight returns the same typed error and the client renders the same CTA. + +**Credit-gated = anything that provisions compute or spends managed model credits:** create session, send message / start a turn, create/wake a sandbox. Read-only views (history, billing, settings) stay open. + +## Which changes where + +| Area | File | Change | +|---|---|---| +| Dashboard shell | `web/src/components/app-shell.tsx` (`HaltBanner`) | stronger + sticky treatment; keep CTA (A1) | +| Dashboard sessions | `web/src/pages/Sessions*.tsx` / `SessionDetail*` + composer | inline halted state at the action; disable send/new/run when `isHalted` (A2 + B3) | +| Dashboard client | `web/src/api/client.ts` | no data change (`getAutumnBilling` suffices); render the preflight `insufficient_credits` error inline (ties to Slice 1a) | +| Edge | `cloudflare-workers/api-edge/src/index.ts` | preflight `is_halted` on the v3 turn/session-create path — extend the `:443`/`:859` pattern to every credit-gated route it fronts | +| Runtime / sessions-api | provision/accept path + fallback classifier | preflight halt → terminal `insufficient_credits`; classify `402 credits` as terminal (no `runtime.fallback` retry) | + +## How it works (flow) + +1. Balance ≤ 0 → edge `model_meter` halts the org (`is_halted=1`) — existing. +2. Dashboard reads `isHalted` → **sticky halted banner** + **credit-gated controls disabled with an inline top-up CTA** (Parts A + B3). +3. If a credit-gated request still arrives (API/SDK user, or a UI race), the **preflight** returns a typed terminal `insufficient_credits` immediately — no sandbox spin-up, no fallback loop (B1/B2). +4. Top-up → Autumn credits land → fast un-halt (Slice 1c) → `is_halted` flips → banner clears + controls re-enable within the poll window. + +## Relationship to billing-usage-and-topup.md + +Refines **Slice 1** ("clear path when credits run out"): adds (A) the *unmissable/sticky* halted state + control-gating and (B) *preflight* early-fail + terminal-402 classification — the "fail before it starts" half that Slice 1a (reactive CTA after a failure) doesn't cover. Deferred to the billing doc: usage/remaining breakdown (S2), monthly auto-top-up cap (S3), per-agent attribution (needs `usage-not-wired`). + +## Open questions + +1. **Preflight choke point** — edge (earliest; fronts the API) vs sessions-api turn-accept (authoritative for the turn) vs both. +2. **Exact credit-gated route list** — confirm every provisioning entry (create session, message/steer→turn, sandbox create/wake) is covered; don't gate read-only routes. +3. **SDK/API users** (no dashboard) — the typed `insufficient_credits` 402 is their only signal; make its body actionable (message + a "top up" link/remediation hint). +4. **Prominence bar** — sticky banner + control-gating (recommended) vs a full blocking interstitial when fully halted. From b3d4a3afb574f8b1207d5cd46784de5a8e531d85 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Fri, 3 Jul 2026 01:29:05 +0100 Subject: [PATCH 2/5] =?UTF-8?q?out-of-credits=20UX:=20the=20provision=2040?= =?UTF-8?q?2=20IS=20the=20preflight=20=E2=80=94=20memo=20it;=20park,=20don?= =?UTF-8?q?'t=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass with two topology facts that reshape Part B: - The public /v3 API is direct-to-Fly — the CF edge halt-gates can never be authoritative (SDK users bypass them). Authority must live in sessions-api. - sessions-api has no local halt state, and a per-turn cross-service halt check is the wrong trade (a hop on every turn to save one doomed provision on the rare halted org). Resolution: classify the provision 402-credits as TERMINAL (kills the ~6x runtime.fallback loop — that ladder is for missing snapshots, not billing), then MEMO the halt (TTL ~60s, cleared on success): later create/message calls fail synchronously with a typed 402, accept parks. Zero new coupling, self-healing on top-up. Also names the input-disposition decision the draft skipped: PARK the unconsumed input and re-kick on un-halt ("top up and your agent resumes where you left off") instead of consume-and-fail — the durable-sessions promise, from machinery that already exists. Open questions 1-3 answered in place; added memo-storage as the one genuinely open item. Co-Authored-By: Claude Fable 5 --- .agents/work/out-of-credits-ux.md | 89 +++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/.agents/work/out-of-credits-ux.md b/.agents/work/out-of-credits-ux.md index 24468541..94cedb9b 100644 --- a/.agents/work/out-of-credits-ux.md +++ b/.agents/work/out-of-credits-ux.md @@ -37,11 +37,53 @@ No new data needed — `getAutumnBilling.isHalted` / `creditsRemainingCents` alr Principle: **if we know an operation can't succeed, refuse it at the boundary with a specific message — never let it run deep and die generic.** -1. **Preflight the halt on the v3 turn/session path.** The failing trace shows the turn reaching `sandbox.ensure` → deep `402` with no halt check first (unlike the edge create/wake gates). Add a preflight halt check on session-create / turn-accept / provision so a halted org yields an immediate typed terminal `insufficient_credits` — no sandbox attempt. Mirror the existing edge gates (`index.ts:443/859`). -2. **Classify 402-credits as terminal — kill the fallback loop.** `runtime.fallback` retried the 402 ~6×; that last-known-good-runtime fallback is for **missing snapshots** (infra), not billing. A `402 credits exhausted` must be terminal-non-retriable → stop after one and emit the clean `insufficient_credits` signal. Cheap, and it de-noises telemetry (these log as `provision` errors today). -3. **Gate the UI controls.** When `isHalted`, disable/replace the credit-gated affordances — "New session", the composer's send, "Run" — with an inline "Out of credits — top up to resume →" (reusing the halted state + Slice 1a's CTA). The user shouldn't be able to fire an op we know will fail; if they do (race / API user), the B1 preflight returns the same typed error and the client renders the same CTA. - -**Credit-gated = anything that provisions compute or spends managed model credits:** create session, send message / start a turn, create/wake a sandbox. Read-only views (history, billing, settings) stay open. +Two facts that shape the design (verified against the deployed topology): + +- **The public `/v3` API does NOT go through the CF edge.** `api.opencomputer.dev` is + sessions-api direct (Fly); only dashboard traffic and the sandbox API ride the edge. + So the existing edge halt-gates (`index.ts:443/859`) can never be the authoritative + check — an SDK/API user bypasses them entirely. Authority has to live in sessions-api. +- **sessions-api has no local halt state, and shouldn't buy one with a per-turn + cross-service read.** The halt lives in the edge D1 / Autumn. A synchronous preflight + against either adds a network hop to EVERY turn to save one doomed provision attempt + on the rare halted org — wrong trade. + +The resolution: **the provision 402 IS the preflight — remember it.** + +1. **Classify 402-credits at provision as TERMINAL — kill the fallback loop.** The + last-known-good-runtime fallback exists for missing/corrupt snapshots (infra); + `402 credits exhausted` is org-level — no build in the ladder can succeed. One + attempt → typed terminal `insufficient_credits` + ONE clean user-facing message + (reuse the runtime's existing `insufficient_credits` event shape so the dashboard + already knows how to render it). De-noises telemetry too (today these log ~6× as + generic `provision` errors). +2. **Halt memo (fail-fast without new coupling).** When any credit-gated op hits a + 402-credits, sessions-api memoizes `halted_until` for the org (in-process or a + session-row-adjacent note, TTL ~60s, cleared by any successful provision). While the + memo is live: session-create and message-append return a **synchronous typed 402** + (`{error:{type:"insufficient_credits", message, top_up_url}}`) and turn-accept + parks instead of provisioning. First doomed op pays one provision round-trip; + everything after fails instantly; top-up self-heals via TTL — zero new cross-service + reads, no config, no sync job. +3. **Gate the UI controls.** When `isHalted`, disable/replace the credit-gated + affordances — "New session", the composer's send, "Run" — with an inline "Out of + credits — top up to resume →" (reusing the halted state + Slice 1a's CTA). If a + request fires anyway (race / API user), B2's memo returns the same typed error and + the client renders the same CTA. +4. **Decide the input's fate: park-and-resume (recommended) over consume-and-fail.** + When a turn dies on provision-402, the user's message never ran. If the turn + consumes the input (the model-402 path's anti-spam behavior), the user must RESEND + after topping up. Better: leave the input window unconsumed, suppress re-fires with + the halt memo, and on un-halt (Slice 1c's hook) re-kick sessions with pending + input — "top up and your agent picks up where you left off" is exactly the durable- + sessions promise, and it falls out of machinery that already exists (wakeups + + input cursor). The model-call-402 mid-turn keeps today's consume behavior (the turn + DID run and answered with the credit notice). + +**Credit-gated = anything that provisions compute or spends managed model credits:** +create session, send message / start a turn (including watch-delivery wakeups — a +watched PR event on a halted org must park, not loop), create/wake a sandbox. Read-only +views (history, billing, settings) stay open. ## Which changes where @@ -49,24 +91,39 @@ Principle: **if we know an operation can't succeed, refuse it at the boundary wi |---|---|---| | Dashboard shell | `web/src/components/app-shell.tsx` (`HaltBanner`) | stronger + sticky treatment; keep CTA (A1) | | Dashboard sessions | `web/src/pages/Sessions*.tsx` / `SessionDetail*` + composer | inline halted state at the action; disable send/new/run when `isHalted` (A2 + B3) | -| Dashboard client | `web/src/api/client.ts` | no data change (`getAutumnBilling` suffices); render the preflight `insufficient_credits` error inline (ties to Slice 1a) | -| Edge | `cloudflare-workers/api-edge/src/index.ts` | preflight `is_halted` on the v3 turn/session-create path — extend the `:443`/`:859` pattern to every credit-gated route it fronts | -| Runtime / sessions-api | provision/accept path + fallback classifier | preflight halt → terminal `insufficient_credits`; classify `402 credits` as terminal (no `runtime.fallback` retry) | +| Dashboard client | `web/src/api/client.ts` | no data change (`getAutumnBilling` suffices); render the typed `insufficient_credits` error inline (ties to Slice 1a) | +| Edge | `cloudflare-workers/api-edge/src/index.ts` | keep the existing `:443`/`:859` gates as the dashboard-path fast refusal; nothing new — the edge is NOT the authority (public /v3 bypasses it) | +| sessions-api | provision fallback classifier + accept + create/messages routes | 402-credits terminal (no `runtime.fallback` retry, one clean user event); halt memo → sync typed 402 on create/messages + park at accept; un-halt re-kick of parked input | ## How it works (flow) 1. Balance ≤ 0 → edge `model_meter` halts the org (`is_halted=1`) — existing. 2. Dashboard reads `isHalted` → **sticky halted banner** + **credit-gated controls disabled with an inline top-up CTA** (Parts A + B3). -3. If a credit-gated request still arrives (API/SDK user, or a UI race), the **preflight** returns a typed terminal `insufficient_credits` immediately — no sandbox spin-up, no fallback loop (B1/B2). -4. Top-up → Autumn credits land → fast un-halt (Slice 1c) → `is_halted` flips → banner clears + controls re-enable within the poll window. +3. If a credit-gated request still arrives (API/SDK user, or a UI race), the FIRST one pays a single provision attempt and dies typed + terminal (B1); the halt memo then makes every subsequent create/message fail **synchronously** with the same typed 402 (B2). Pending input parks — no retry loop, nothing lost (B4). +4. Top-up → Autumn credits land → fast un-halt (Slice 1c) → `is_halted` flips → banner clears + controls re-enable within the poll window → parked sessions re-kick and resume the turn the user already asked for. ## Relationship to billing-usage-and-topup.md Refines **Slice 1** ("clear path when credits run out"): adds (A) the *unmissable/sticky* halted state + control-gating and (B) *preflight* early-fail + terminal-402 classification — the "fail before it starts" half that Slice 1a (reactive CTA after a failure) doesn't cover. Deferred to the billing doc: usage/remaining breakdown (S2), monthly auto-top-up cap (S3), per-agent attribution (needs `usage-not-wired`). -## Open questions - -1. **Preflight choke point** — edge (earliest; fronts the API) vs sessions-api turn-accept (authoritative for the turn) vs both. -2. **Exact credit-gated route list** — confirm every provisioning entry (create session, message/steer→turn, sandbox create/wake) is covered; don't gate read-only routes. -3. **SDK/API users** (no dashboard) — the typed `insufficient_credits` 402 is their only signal; make its body actionable (message + a "top up" link/remediation hint). -4. **Prominence bar** — sticky banner + control-gating (recommended) vs a full blocking interstitial when fully halted. +## Open questions (1–3 resolved in Part B, kept for the record) + +1. **Preflight choke point — RESOLVED: sessions-api, via the 402 memo.** The edge can't + be the authority (public `/v3` is direct-to-Fly and bypasses it); a synchronous + cross-service halt check on every turn is the wrong trade. The memo gives fail-fast + authority to sessions-api with zero new coupling; the existing edge gates stay as a + dashboard-path nicety. +2. **Credit-gated route list — RESOLVED:** `POST /v3/sessions`, + `POST /v3/sessions/:id/messages` (and anything else that kicks a turn — watch + deliveries included, at accept), sandbox create/wake (edge-owned). Read-only routes + untouched. +3. **SDK/API users — RESOLVED:** the sync 402 body is + `{error:{type:"insufficient_credits", message:"Out of prepaid credits — top up to + resume.", top_up_url}}`; same shape from the memo path and the terminal turn event, + so one client rendering covers both. +4. **Prominence bar** — sticky banner + control-gating (recommended) vs a full blocking + interstitial when fully halted. Lean: no interstitial — read-only use (reviewing + past sessions, billing) must stay frictionless; the gated controls ARE the block + where it matters. +5. **NEW — memo storage:** in-process Map (dies on deploy — fine, TTL 60s) vs a column. + Lean: in-process; the cost of a miss is one extra doomed provision attempt. From 7f1cb256f83bd854a22fb70c4d303ad1a6792d65 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Fri, 3 Jul 2026 01:48:37 +0100 Subject: [PATCH 3/5] =?UTF-8?q?dashboard:=20out-of-credits=20=E2=80=94=20s?= =?UTF-8?q?ticky=20banner=20+=20gate=20credit-gated=20controls=20(Part=20A?= =?UTF-8?q?=20+=20B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the dashboard half of the out-of-credits doc on the same branch. A1 — HaltBanner (app-shell) is now sticky (stays in view while scrolling) and error-toned with an icon, so the halt reads as a hard block, not a muted notice. B3 — gate the controls at the moment of action (a user who never scrolls up still can't miss it), via a shared useHalted() hook (dedupes the ['autumn-billing'] query with the banner): - SessionDetail composer: disabled + "Out of credits — top up to resume" inline CTA and placeholder when halted; send guarded. - Sessions "Start session": disabled with a top-up tooltip when halted. Client: ApiError now carries the API's typed `type`, so a 402 insufficient_credits is distinguishable. On a create/steer credits refusal (race / stale state), invalidate ['autumn-billing'] so the banner + gating appear immediately instead of on the poll. Pairs with the runtime/API fail-fast (sessions-api #54, merged). web tsc -b + vite build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/client.ts | 18 ++++++++++++-- web/src/components/app-shell.tsx | 22 +++++++++-------- web/src/hooks/useHalted.ts | 15 ++++++++++++ web/src/pages/SessionDetail.tsx | 42 +++++++++++++++++++++++++------- web/src/pages/Sessions.tsx | 24 +++++++++++++++--- 5 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 web/src/hooks/useHalted.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 433f15b6..1a772926 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -81,11 +81,25 @@ function errorMessage(body: unknown, status: number): string { return `Request failed: ${status}` } -/** An error carrying the HTTP status, so callers can branch (e.g. 404 = not-found vs a real failure). */ +// The typed discriminator sessions-api puts on `{error:{type,…}}` (e.g. +// "insufficient_credits"), so callers can branch on the reason, not the status alone. +function errorType(body: unknown): string | undefined { + if (body && typeof body === 'object') { + const err = (body as Record).error + if (err && typeof err === 'object' && typeof (err as Record).type === 'string') { + return (err as Record).type as string + } + } + return undefined +} + +/** An error carrying the HTTP status + the API's typed reason, so callers can branch + * (e.g. 404 = not-found; 402 `insufficient_credits` = out of credits → top-up CTA). */ export class ApiError extends Error { constructor( message: string, readonly status: number, + readonly type?: string, ) { super(message) this.name = 'ApiError' @@ -123,7 +137,7 @@ export async function apiFetch( if (!res.ok) { const body: unknown = await res.json().catch(() => ({})) - throw new ApiError(errorMessage(body, res.status), res.status) + throw new ApiError(errorMessage(body, res.status), res.status, errorType(body)) } if (res.status === 204) { diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index f8d20a53..b9cdeb97 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -19,6 +19,7 @@ import { Check, Menu, Loader2, + CircleAlert, type LucideIcon, } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' @@ -254,17 +255,18 @@ function HaltBanner() { }) const halted = data?.isHalted ?? false if (!halted || location.pathname.startsWith('/billing')) return null + // Sticky + error-toned so the halt stays in view while scrolling and reads as a hard + // block, not a muted notice (out-of-credits doc, Part A1). Sits just below the top bar. return ( -
- Your agent sessions and sandboxes are paused — you're out of prepaid - credits.{' '} - - Top up & turn on auto-recharge - {' '} - to resume now and prevent future pauses. +
+ + + Your agent sessions and sandboxes are paused — you're out of prepaid credits.{' '} + + Top up & turn on auto-recharge + {' '} + to resume. +
) } diff --git a/web/src/hooks/useHalted.ts b/web/src/hooks/useHalted.ts new file mode 100644 index 00000000..bedb024c --- /dev/null +++ b/web/src/hooks/useHalted.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { getAutumnBilling } from '@/api/client' + +// Out-of-credits (halt) state, shared across the halt banner and the credit-gated +// controls (composer, new-session) so they stay in sync and share one 30s poll. +// Autumn orgs only; legacy orgs 404 on /billing/autumn → error → treated as not halted. +export function useHalted(): boolean { + const { data } = useQuery({ + queryKey: ['autumn-billing'], + queryFn: getAutumnBilling, + retry: false, + refetchInterval: (q) => (q.state.error ? false : 30_000), + }) + return data?.isHalted ?? false +} diff --git a/web/src/pages/SessionDetail.tsx b/web/src/pages/SessionDetail.tsx index 26111810..1c17f1d3 100644 --- a/web/src/pages/SessionDetail.tsx +++ b/web/src/pages/SessionDetail.tsx @@ -1,14 +1,16 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, Send, Wrench } from 'lucide-react' +import { ArrowLeft, Send, Wrench, CircleAlert } from 'lucide-react' import { notifyError } from '@/lib/errors' +import { useHalted } from '@/hooks/useHalted' import { getSession, getSessionEvents, sendMessage, cancelSession, archiveSession, + ApiError, type SessionEvent, } from '@/api/client' import { SessionEventSchema } from '@/api/schemas' @@ -119,7 +121,14 @@ export default function SessionDetail() { setDraft('') invalidate() }, - onError: (e) => notifyError("Couldn't send the message.", e), + onError: (e) => { + // Race/stale-state fallback: if the server refused on credits, refresh the halt + // state so the banner + composer gating appear at once (don't wait for the poll). + if (e instanceof ApiError && e.type === 'insufficient_credits') { + void queryClient.invalidateQueries({ queryKey: ['autumn-billing'] }) + } + notifyError("Couldn't send the message.", e) + }, }) const cancelMutation = useMutation({ mutationFn: () => cancelSession(sessionId), @@ -154,6 +163,7 @@ export default function SessionDetail() { const status = session?.status ?? 'unknown' const archived = status === 'archived' + const halted = useHalted() const canSteer = !archived const canCancel = status === 'running' || status === 'awaiting_input' @@ -290,33 +300,47 @@ export default function SessionDetail() { {/* Steer */}
+ {halted && !archived && ( +
+ + + Out of credits —{' '} + + top up to resume + + . + +
+ )}
{ e.preventDefault() - if (draft.trim() && canSteer) steerMutation.mutate() + if (draft.trim() && canSteer && !halted) steerMutation.mutate() }} > setDraft(e.target.value)} onSend={() => { - if (draft.trim() && canSteer && !steerMutation.isPending) { + if (draft.trim() && canSteer && !halted && !steerMutation.isPending) { steerMutation.mutate() } }} placeholder={ - canSteer - ? 'Send a message to steer the session…' - : 'Session archived' + halted + ? 'Out of credits — top up to resume' + : canSteer + ? 'Send a message to steer the session…' + : 'Session archived' } - disabled={!canSteer} + disabled={!canSteer || halted} className="min-h-10 flex-1" /> @@ -155,7 +166,12 @@ export default function Sessions() { title="No sessions yet" description="Start a session from an agent to give it a task; it runs durably and streams events here." action={ - From 5ddb0db4b9d0e8a0fb1578bce1940b3ee9067214 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Fri, 3 Jul 2026 01:51:48 +0100 Subject: [PATCH 4/5] dashboard: in-thread out-of-credits message gets an actionable Top up button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime posts a user-level agent.message coded insufficient_credits when a turn dies on a provision-402 (sessions-api #54). Render that message with a "Top up" button → /billing (the top-up flow), so the reason isn't just text. Keyed off the event body's `code`, so ordinary agent messages are unaffected. web tsc -b + vite build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/pages/SessionDetail.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/pages/SessionDetail.tsx b/web/src/pages/SessionDetail.tsx index 1c17f1d3..11293bbe 100644 --- a/web/src/pages/SessionDetail.tsx +++ b/web/src/pages/SessionDetail.tsx @@ -375,6 +375,11 @@ function EventRow({ ev }: { ev: SessionEvent }) { // Conversation messages — the signal. if (ev.type === 'user.message' || ev.type === 'agent.message') { const isUser = ev.type === 'user.message' + // Out-of-credits notice (from the runtime's failPreExec) → make the "top up" actionable. + const outOfCredits = + ev.type === 'agent.message' && + (ev.body as Record | null | undefined)?.code === + 'insufficient_credits' return (
  • @@ -386,6 +391,11 @@ function EventRow({ ev }: { ev: SessionEvent }) {

    {text}

    + {outOfCredits && ( + + )}
  • ) } From 58285f9343ab35ffd26a745d59a505ae241dbcc9 Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Fri, 3 Jul 2026 01:58:31 +0100 Subject: [PATCH 5/5] fix(dashboard): call useHalted() before SessionDetail's early return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useHalted() was placed after `if (isLoading) return`, so on session start the loading→loaded transition changed the hook count → "Rendered more hooks than during the previous render" → the view's error boundary. Move it to the top with the other hooks (before any early return). Sessions.tsx was already top-level. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/pages/SessionDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/SessionDetail.tsx b/web/src/pages/SessionDetail.tsx index 11293bbe..a2e0220f 100644 --- a/web/src/pages/SessionDetail.tsx +++ b/web/src/pages/SessionDetail.tsx @@ -57,6 +57,7 @@ type LevelFilter = (typeof LEVELS)[number]['value'] export default function SessionDetail() { const { sessionId = '' } = useParams() const queryClient = useQueryClient() + const halted = useHalted() // top-level: must run before any early return (Rules of Hooks) const [draft, setDraft] = useState('') const [level, setLevel] = useState('all') const [confirmCancel, setConfirmCancel] = useState(false) @@ -163,7 +164,6 @@ export default function SessionDetail() { const status = session?.status ?? 'unknown' const archived = status === 'archived' - const halted = useHalted() const canSteer = !archived const canCancel = status === 'running' || status === 'awaiting_input'