diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97ade26d4..271830ed0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ See `docs/llm-wiki/release.md`.
- **Account heatmap stats strip**: Codex-style totals — cumulative tokens, peak day, longest chat, current / longest streak — plus a **Cumulative** calendar view (running-total color).
### Fixed
+- **Usage modal cache > input, then empty**: Session spend only sums `turn_completed` snapshots. Cache-heavy fragments (cache > input, no `modelCalls`) are dropped so cached cannot exceed input; a real turn without `modelCalls` still counts. Totals persist in sessionStorage, and an in-flight turn says usage updates when it finishes.
- **Chat image cards no longer die after the turn ends**: Leftover remote https thumbs (web-fetch charts, etc.) first-paint from the in-memory thumb cache on journal remount. Swapping `src` mid-load used to abort the original `
` and lock `broken_blob` (“preview failed”). Abort / stale-src errors are ignored; a working https original is not wiped when thumb resolve returns empty.
## [0.2.17] - 2026-08-14
diff --git a/src/app/AppWorkbench.tsx b/src/app/AppWorkbench.tsx
index 21b8996ba..c34772883 100644
--- a/src/app/AppWorkbench.tsx
+++ b/src/app/AppWorkbench.tsx
@@ -21739,6 +21739,10 @@ export function AppWorkbench() {
spend={sessionSpend}
account={account}
customRoute={customRouteActive}
+ turnActive={
+ session.state === "streaming" ||
+ session.state === "awaiting_permission"
+ }
onClose={() => setShowUsageLimitModal(false)}
/>
{(agentDashboardOpen) ? (
diff --git a/src/components/UsageLimitModal.tsx b/src/components/UsageLimitModal.tsx
index 89e20ceea..df65eda97 100644
--- a/src/components/UsageLimitModal.tsx
+++ b/src/components/UsageLimitModal.tsx
@@ -29,6 +29,8 @@ type Props = {
spend: SessionSpend;
account: AccountStatus | null;
customRoute?: boolean;
+ /** True while a turn is in flight — usage lands on turn_completed. */
+ turnActive?: boolean;
onClose: () => void;
};
@@ -46,6 +48,7 @@ export function UsageLimitModal({
spend,
account,
customRoute = false,
+ turnActive = false,
onClose,
}: Props) {
const tr = useMemo(() => createT(locale), [locale]);
@@ -160,7 +163,11 @@ export function UsageLimitModal({
{!sessionId ? (
+ {turnActive
+ ? tr("usageModal.pendingTurn")
+ : tr("usageModal.noCalls")}
+
) : (
diff --git a/src/i18n/messages/en/account.ts b/src/i18n/messages/en/account.ts
index 7c58dd706..26e8a5d11 100644
--- a/src/i18n/messages/en/account.ts
+++ b/src/i18n/messages/en/account.ts
@@ -52,6 +52,7 @@ export const enAccount = {
"usageModal.cost": "Cost",
"usageModal.noSession": "Session usage is unavailable until the session starts.",
"usageModal.noCalls": "No model calls yet in this session.",
+ "usageModal.pendingTurn": "Usage updates when this turn finishes.",
"usageModal.incomplete": "Usage is incomplete and may under-count.",
"usageModal.costPartial": "Cost is incomplete and may under-count.",
"usageModal.quotaUnknown": "Could not load the weekly limit.",
diff --git a/src/i18n/messages/zh-TW/account.ts b/src/i18n/messages/zh-TW/account.ts
index 935fa341b..e0badc88f 100644
--- a/src/i18n/messages/zh-TW/account.ts
+++ b/src/i18n/messages/zh-TW/account.ts
@@ -52,6 +52,7 @@ export const zhTWAccount = {
"usageModal.cost": "費用",
"usageModal.noSession": "對話尚未開始,暫無用量。",
"usageModal.noCalls": "本對話還沒有模型呼叫。",
+ "usageModal.pendingTurn": "用量在本回合結束後更新。",
"usageModal.incomplete": "用量追蹤不完整,數字可能偏低。",
"usageModal.costPartial": "費用追蹤不完整,數字可能偏低。",
"usageModal.quotaUnknown": "無法載入每週限額。",
diff --git a/src/i18n/messages/zh/account.ts b/src/i18n/messages/zh/account.ts
index 2dfecc3da..6b3e0378c 100644
--- a/src/i18n/messages/zh/account.ts
+++ b/src/i18n/messages/zh/account.ts
@@ -52,6 +52,7 @@ export const zhAccount = {
"usageModal.cost": "费用",
"usageModal.noSession": "会话尚未开始,暂无用量。",
"usageModal.noCalls": "本会话还没有模型调用。",
+ "usageModal.pendingTurn": "用量在本回合结束后更新。",
"usageModal.incomplete": "用量跟踪不完整,数字可能偏低。",
"usageModal.costPartial": "费用跟踪不完整,数字可能偏低。",
"usageModal.quotaUnknown": "未能加载每周限额。",
diff --git a/src/lib/sessionSpend.test.ts b/src/lib/sessionSpend.test.ts
index 9d1aa1ea8..7c6a337d2 100644
--- a/src/lib/sessionSpend.test.ts
+++ b/src/lib/sessionSpend.test.ts
@@ -34,7 +34,8 @@ afterEach(() => {
describe("isSessionSpendBillingSource", () => {
it("accepts turn_completed and rejects occupancy / prompt_result", () => {
expect(isSessionSpendBillingSource("turn_completed")).toBe(true);
- expect(isSessionSpendBillingSource("response_completed")).toBe(true);
+ expect(isSessionSpendBillingSource("response_completed")).toBe(false);
+ expect(isSessionSpendBillingSource("turn_usage")).toBe(false);
expect(isSessionSpendBillingSource("prompt_result")).toBe(false);
expect(isSessionSpendBillingSource("context_size")).toBe(false);
expect(isSessionSpendBillingSource("compact")).toBe(false);
@@ -102,6 +103,72 @@ describe("applySessionSpendTurn", () => {
expect(again.modelCalls).toBe(6);
});
+ it("does not add cache-heavy fragments that lack modelCalls", () => {
+ const once = applySessionSpendTurn(
+ emptySessionSpend(),
+ {
+ source: "turn_completed",
+ inputTokens: 65_402,
+ outputTokens: 2_268,
+ totalTokens: 67_670,
+ cachedReadTokens: 46_848,
+ reasoningTokens: 762,
+ modelCalls: 3,
+ apiDurationMs: 44_692,
+ costUsdTicks: 741_400_000,
+ },
+ 1_000,
+ );
+ const again = applySessionSpendTurn(
+ once,
+ {
+ source: "turn_completed",
+ inputTokens: 22_887,
+ outputTokens: 3_288,
+ cachedReadTokens: 97_152,
+ reasoningTokens: 1_419,
+ },
+ 1_100,
+ );
+ expect(again.inputTokens).toBe(65_402);
+ expect(again.cachedReadTokens).toBe(46_848);
+ expect(again.modelCalls).toBe(3);
+ expect(sessionSpendCacheHitRate(again)).toBe(72);
+ });
+
+ it("accepts a turn_completed snapshot even when modelCalls is missing", () => {
+ const next = applySessionSpendTurn(
+ emptySessionSpend(),
+ {
+ source: "turn_completed",
+ inputTokens: 65_402,
+ outputTokens: 2_268,
+ totalTokens: 67_670,
+ cachedReadTokens: 46_848,
+ },
+ 1_000,
+ );
+ expect(next.inputTokens).toBe(65_402);
+ expect(next.cachedReadTokens).toBe(46_848);
+ expect(sessionSpendCacheHitRate(next)).toBe(72);
+ });
+
+ it("clamps per-turn cache to input", () => {
+ const next = applySessionSpendTurn(
+ emptySessionSpend(),
+ {
+ source: "turn_completed",
+ inputTokens: 10_000,
+ outputTokens: 1,
+ totalTokens: 10_001,
+ cachedReadTokens: 48_000,
+ modelCalls: 1,
+ },
+ 1_000,
+ );
+ expect(next.cachedReadTokens).toBe(10_000);
+ });
+
it("marks incomplete / partial flags", () => {
const next = applySessionSpendTurn(
emptySessionSpend(),
diff --git a/src/lib/sessionSpend.ts b/src/lib/sessionSpend.ts
index abf19aa48..63ebaede7 100644
--- a/src/lib/sessionSpend.ts
+++ b/src/lib/sessionSpend.ts
@@ -63,19 +63,16 @@ export function emptySessionSpend(): SessionSpend {
return { ...EMPTY_SESSION_SPEND };
}
+/**
+ * Only the CLI **user-turn** aggregate. Per-call `response_completed` /
+ * `turn_usage` packets use a different cache accounting (often cache-only
+ * or uncached+cache split) and must not be summed with `turn_completed`.
+ */
export function isSessionSpendBillingSource(
source: string | null | undefined,
): boolean {
const s = (source ?? "").toLowerCase();
- if (!s) return false;
- if (s === "prompt_result") return false;
- return (
- s === "turn_completed" ||
- s === "response_completed" ||
- s.includes("turn_completed") ||
- s === "turn_usage" ||
- s === "turnusage"
- );
+ return s === "turn_completed";
}
function finiteNonNeg(n: number | null | undefined): number | null {
@@ -96,6 +93,19 @@ export function spendTurnFingerprint(turn: SessionSpendTurn): string {
].join(":");
}
+/**
+ * Per-call / split-accounting fragments: cache with no input, or cache > input
+ * and no modelCalls. Those packets must not be summed into the turn snapshot.
+ */
+export function isSpendFragment(turn: SessionSpendTurn): boolean {
+ const input = finiteNonNeg(turn.inputTokens) ?? 0;
+ const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
+ const calls = finiteNonNeg(turn.modelCalls) ?? 0;
+ if (calls > 0) return false;
+ if (cached > 0 && (input <= 0 || cached > input)) return true;
+ return false;
+}
+
export function hasSpendSignal(turn: SessionSpendTurn): boolean {
return (
finiteNonNeg(turn.inputTokens) != null ||
@@ -146,6 +156,7 @@ export function applySessionSpendTurn(
): SessionSpend {
if (!isSessionSpendBillingSource(turn.source)) return state;
if (!hasSpendSignal(turn)) return state;
+ if (isSpendFragment(turn)) return state;
const fp = spendTurnFingerprint(turn);
if (
@@ -158,7 +169,9 @@ export function applySessionSpendTurn(
const input = finiteNonNeg(turn.inputTokens) ?? 0;
const output = finiteNonNeg(turn.outputTokens) ?? 0;
- const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
+ let cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
+ // CLI `inputTokens` already includes cache reads; cache cannot exceed input.
+ if (input > 0 && cached > input) cached = input;
const reasoning = finiteNonNeg(turn.reasoningTokens) ?? 0;
const calls = finiteNonNeg(turn.modelCalls) ?? 0;
const duration = finiteNonNeg(turn.apiDurationMs) ?? 0;
@@ -268,8 +281,43 @@ export function formatUsageResetTime(
// ── In-memory per-session store (App process lifetime) ────────────────
+const SPEND_STORE_KEY = "grok.sessionSpend.v1";
+
const spendBySession = new Map();
const listeners = new Set<(sessionId: string) => void>();
+let storeHydrated = false;
+
+function canUseSessionStorage(): boolean {
+ return typeof sessionStorage !== "undefined";
+}
+
+function persistSpendStore(): void {
+ if (!canUseSessionStorage()) return;
+ try {
+ const obj: Record = {};
+ for (const [id, spend] of spendBySession) obj[id] = spend;
+ sessionStorage.setItem(SPEND_STORE_KEY, JSON.stringify(obj));
+ } catch {
+ /* quota / private mode */
+ }
+}
+
+function hydrateSpendStore(): void {
+ if (storeHydrated) return;
+ storeHydrated = true;
+ if (!canUseSessionStorage()) return;
+ try {
+ const raw = sessionStorage.getItem(SPEND_STORE_KEY);
+ if (!raw) return;
+ const obj = JSON.parse(raw) as Record;
+ for (const [id, spend] of Object.entries(obj ?? {})) {
+ if (!id || !spend || typeof spend !== "object") continue;
+ spendBySession.set(id, { ...emptySessionSpend(), ...spend });
+ }
+ } catch {
+ /* ignore bad cache */
+ }
+}
function notify(sessionId: string): void {
for (const fn of listeners) {
@@ -282,12 +330,15 @@ function notify(sessionId: string): void {
}
export function getSessionSpend(sessionId: string | null | undefined): SessionSpend {
+ hydrateSpendStore();
if (!sessionId) return emptySessionSpend();
return spendBySession.get(sessionId) ?? emptySessionSpend();
}
export function resetSessionSpend(sessionId: string): void {
+ hydrateSpendStore();
spendBySession.delete(sessionId);
+ persistSpendStore();
notify(sessionId);
}
@@ -296,10 +347,12 @@ export function ingestSessionSpend(
turn: SessionSpendTurn,
now = Date.now(),
): SessionSpend {
+ hydrateSpendStore();
const prev = spendBySession.get(sessionId) ?? emptySessionSpend();
const next = applySessionSpendTurn(prev, turn, now);
if (next === prev) return prev;
spendBySession.set(sessionId, next);
+ persistSpendStore();
notify(sessionId);
return next;
}
@@ -316,4 +369,12 @@ export function subscribeSessionSpend(
/** Test-only: wipe the process map. */
export function clearSessionSpendStore(): void {
spendBySession.clear();
+ storeHydrated = true;
+ if (canUseSessionStorage()) {
+ try {
+ sessionStorage.removeItem(SPEND_STORE_KEY);
+ } catch {
+ /* ignore */
+ }
+ }
}