diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5199c4ae50..ebc503234b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,17 @@
### Fixed
+- The composer's context gauge now says so instead of holding the pre-compaction
+ figure after a fold. A compaction replaces the prompt the last measured request
+ described, and no provider has tokenized the replacement yet, so the newest real
+ count is stale rather than current — showing it read as a live measurement of
+ what the session was about to send. The gauge walks the transcript backwards and
+ whichever fact comes first decides: a `context_compacted` note newer than every
+ measurement renders `?` with a tooltip saying why, and a measurement newer than
+ the note stands. The live per-settled-request snapshot still wins when it landed
+ after the boundary, so a mid-turn fold recovers as soon as the next step settles
+ rather than waiting for the turn to end. A failed-open fold is not a boundary:
+ that request went out with its full raw history.
- Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth
exceeded") coming from the composer's prompt-history inline completion (#4117): the
offer engine the 0.1.11 composer fed could flip-flop its announcement state on
diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts
index 301f8aeffa..d2038ed08e 100644
--- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts
+++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts
@@ -19,22 +19,36 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { selectLatestRequestUsage } from '../../renderer/chat-composer-region.js';
+import {
+ resolveContextUsage,
+ selectLatestRequestUsage,
+} from '../../renderer/application/contracts/session-inspector/latest-request-usage.js';
const ROUTE = { llmConnectionId: 'conn-a' };
const MODEL = 'model-a';
-function usage(anchor?: {
- inputTokens: number;
- outputTokens?: number;
- modelId?: string;
- connectionId?: string;
-}) {
- return { type: 'token_usage', ...(anchor ? { lastRequestAnchor: anchor } : {}) };
+function usage(
+ anchor?: {
+ inputTokens: number;
+ outputTokens?: number;
+ modelId?: string;
+ connectionId?: string;
+ },
+ ts?: number,
+) {
+ return {
+ type: 'token_usage',
+ ...(ts !== undefined ? { ts } : {}),
+ ...(anchor ? { lastRequestAnchor: anchor } : {}),
+ };
+}
+
+function compactionNote(kind: string, ts?: number) {
+ return { type: 'system_note', kind, ...(ts !== undefined ? { ts } : {}) };
}
test('reads the newest anchor on the active route', () => {
- const tokens = selectLatestRequestUsage(
+ const reading = selectLatestRequestUsage(
[
usage({ inputTokens: 10, outputTokens: 2, modelId: MODEL, connectionId: 'conn-a' }),
{ type: 'assistant' },
@@ -43,14 +57,14 @@ test('reads the newest anchor on the active route', () => {
MODEL,
ROUTE,
);
- assert.equal(tokens, 120);
+ assert.deepEqual(reading, { kind: 'tokens', tokens: 120 });
});
test('scans past an anchorless usage row, which is what manual compaction writes', () => {
// `/compact` appends a synthetic `token_usage` with no anchor. The runtime's
// own reader skips it and keeps the last real request; stopping there would
- // blank the indicator after every manual compaction.
- const tokens = selectLatestRequestUsage(
+ // read the fold's own record as a count of zero.
+ const reading = selectLatestRequestUsage(
[
usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
usage(),
@@ -58,37 +72,85 @@ test('scans past an anchorless usage row, which is what manual compaction writes
MODEL,
ROUTE,
);
- assert.equal(tokens, 120);
+ assert.deepEqual(reading, { kind: 'tokens', tokens: 120 });
+});
+
+test('a compaction boundary newer than every measurement supersedes it', () => {
+ // The fold replaced the prompt the newest count described, and nothing has
+ // measured the replacement. The stale figure must not be shown as a live
+ // reading of what the session is about to send.
+ const reading = selectLatestRequestUsage(
+ [
+ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000),
+ compactionNote('context_compacted', 2_000),
+ usage(undefined, 2_100),
+ ],
+ MODEL,
+ ROUTE,
+ );
+ assert.deepEqual(reading, { kind: 'compacted', at: 2_000 });
+});
+
+test('a measurement newer than the boundary stands, which is the post-fold reading', () => {
+ const reading = selectLatestRequestUsage(
+ [
+ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000),
+ compactionNote('context_compacted', 2_000),
+ usage({ inputTokens: 30, outputTokens: 5, modelId: MODEL, connectionId: 'conn-a' }, 3_000),
+ ],
+ MODEL,
+ ROUTE,
+ );
+ assert.deepEqual(reading, { kind: 'tokens', tokens: 35 });
+});
+
+test('a failed-open fold is not a boundary', () => {
+ // The fold was refused and the request went out with its full raw history, so
+ // the measurement behind the note still describes what was sent.
+ const reading = selectLatestRequestUsage(
+ [
+ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }),
+ compactionNote('context_compaction_failed_open'),
+ ],
+ MODEL,
+ ROUTE,
+ );
+ assert.deepEqual(reading, { kind: 'tokens', tokens: 120 });
+});
+
+test('a boundary with no measurement behind it is still a superseded reading', () => {
+ const reading = selectLatestRequestUsage([compactionNote('context_compacted')], MODEL, ROUTE);
+ assert.deepEqual(reading, { kind: 'compacted' });
});
test('refuses an anchor from another model', () => {
// A token count is a number in one model's tokenizer. Pairing model A's
// count with model B's window produces a precise-looking figure about a
// request the user is not making.
- const tokens = selectLatestRequestUsage(
+ const reading = selectLatestRequestUsage(
[usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })],
MODEL,
ROUTE,
);
- assert.equal(tokens, undefined);
+ assert.equal(reading, undefined);
});
test('refuses an anchor from another connection', () => {
- const tokens = selectLatestRequestUsage(
+ const reading = selectLatestRequestUsage(
[usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })],
MODEL,
ROUTE,
);
- assert.equal(tokens, undefined);
+ assert.equal(reading, undefined);
});
test('refuses an anchor written before anchors carried their route', () => {
- const tokens = selectLatestRequestUsage(
+ const reading = selectLatestRequestUsage(
[usage({ inputTokens: 100, outputTokens: 20 })],
MODEL,
ROUTE,
);
- assert.equal(tokens, undefined);
+ assert.equal(reading, undefined);
});
test('refuses when there is no active route yet', () => {
@@ -98,10 +160,101 @@ test('refuses when there is no active route yet', () => {
});
test('refuses a non-positive input count', () => {
- const tokens = selectLatestRequestUsage(
+ const reading = selectLatestRequestUsage(
[usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })],
MODEL,
ROUTE,
);
- assert.equal(tokens, undefined);
+ assert.equal(reading, undefined);
+});
+
+test('the snapshot is the reading when it is the newer answer', () => {
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'tokens', tokens: 120 },
+ live: { usageTokens: 130, completedAt: 1_500 },
+ }),
+ { kind: 'measured', tokens: 130 },
+ );
+ // No snapshot at all leaves the anchor standing.
+ assert.deepEqual(
+ resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120 } }),
+ { kind: 'measured', tokens: 120 },
+ );
+ // The snapshot can still vouch when the transcript established nothing.
+ assert.deepEqual(
+ resolveContextUsage({ latestRequestUsage: undefined, live: { usageTokens: 130 } }),
+ { kind: 'measured', tokens: 130 },
+ );
+ assert.deepEqual(resolveContextUsage({ latestRequestUsage: undefined }), {
+ kind: 'unavailable',
+ });
+});
+
+test('a boundary supersedes the snapshot it landed after', () => {
+ // The manual `/compact` case: the snapshot still describes the pre-fold
+ // prompt, so the gauge says unknown rather than holding that figure.
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'compacted', at: 2_000 },
+ live: { usageTokens: 90_000, completedAt: 1_000 },
+ }),
+ { kind: 'stale', reason: 'compaction' },
+ );
+ assert.deepEqual(
+ resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 } }),
+ { kind: 'stale', reason: 'compaction' },
+ );
+ // An untimed snapshot cannot be shown to be newer than a boundary, and a
+ // guess in that position is the precise-looking lie this state exists to
+ // refuse.
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'compacted', at: 2_000 },
+ live: { usageTokens: 90_000 },
+ }),
+ { kind: 'stale', reason: 'compaction' },
+ );
+});
+
+test('a snapshot newer than the boundary is the post-fold reading', () => {
+ // A mid-turn fold is followed by steps that really do measure the smaller
+ // prompt, so the gauge recovers without waiting for the turn to end.
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'compacted', at: 2_000 },
+ live: { usageTokens: 30_000, completedAt: 2_500 },
+ }),
+ { kind: 'measured', tokens: 30_000 },
+ );
+});
+
+
+test('a selected live measurement carries only its own metered window', () => {
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'tokens', tokens: 120 },
+ live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 },
+ }),
+ { kind: 'measured', tokens: 130, meteredWindow: 1_000 },
+ );
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'compacted', at: 2_000 },
+ live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 },
+ }),
+ { kind: 'stale', reason: 'compaction' },
+ );
+});
+
+test('equal or missing boundary times cannot establish a post-fold measurement', () => {
+ for (const at of [undefined, 2_000]) {
+ assert.deepEqual(
+ resolveContextUsage({
+ latestRequestUsage: { kind: 'compacted', at },
+ live: { usageTokens: 130, completedAt: 2_000 },
+ }),
+ { kind: 'stale', reason: 'compaction' },
+ );
+ }
});
diff --git a/apps/desktop/src/main/__tests__/live-context-usage.test.ts b/apps/desktop/src/main/__tests__/live-context-usage.test.ts
index 0a7f0abd3d..dead0dea8a 100644
--- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts
+++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts
@@ -83,6 +83,7 @@ function scriptedQuery() {
describe('liveContextUsageFromDiagnostics', () => {
it('maps a matching snapshot onto the gauge, window included', () => {
assert.deepEqual(liveContextUsageFromDiagnostics(available(), ROUTE), {
+ completedAt: 1,
usageTokens: 79_436,
contextWindow: 128_000,
});
@@ -113,7 +114,7 @@ describe('liveContextUsageFromDiagnostics', () => {
it('stands alone without a window', () => {
assert.deepEqual(
liveContextUsageFromDiagnostics(available({ contextWindow: undefined }), ROUTE),
- { usageTokens: 79_436 },
+ { completedAt: 1, usageTokens: 79_436 },
);
});
});
@@ -136,7 +137,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
// The leading `undefined` is the aim itself: whatever stood on screen
// before cannot answer for this target, so it clears before the read.
- assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]);
tracker.dispose();
});
@@ -182,12 +183,12 @@ describe('createLiveContextUsageTracker', () => {
assert.equal(query.pending.length, 1);
timer.fire();
assert.equal(query.pending.length, 2);
- query.pending[1]!.resolve(available({ inputTokens: 52_000 }));
+ query.pending[1]!.resolve(available({ inputTokens: 52_000, completedAt: 2 }));
await Promise.resolve();
assert.deepEqual(seen, [
undefined,
- { usageTokens: 40_000, contextWindow: 128_000 },
- { usageTokens: 52_000, contextWindow: 128_000 },
+ { completedAt: 1, usageTokens: 40_000, contextWindow: 128_000 },
+ { completedAt: 2, usageTokens: 52_000, contextWindow: 128_000 },
]);
tracker.dispose();
});
@@ -231,7 +232,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
query.pending[0]!.resolve(available({ inputTokens: 10_000 }));
await Promise.resolve();
- assert.deepEqual(seen, [undefined, { usageTokens: 60_000, contextWindow: 128_000 }]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 60_000, contextWindow: 128_000 }]);
tracker.dispose();
});
@@ -254,7 +255,7 @@ describe('createLiveContextUsageTracker', () => {
query.pending[1]!.reject(new Error('host not ready'));
await Promise.resolve();
await Promise.resolve();
- assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]);
tracker.dispose();
});
@@ -276,14 +277,14 @@ describe('createLiveContextUsageTracker', () => {
// Switching sessions makes the standing number unanswerable: it must
// leave the screen BEFORE the new target's first read lands…
tracker.setTarget({ sessionId: 's2', route: ROUTE });
- assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }, undefined]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]);
// …and a rejected first read on the new target keeps it cleared, rather
// than pinning the previous session's number in place indefinitely.
query.pending[1]!.reject(new Error('host not ready'));
await Promise.resolve();
await Promise.resolve();
- assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }, undefined]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]);
tracker.dispose();
});
@@ -310,7 +311,7 @@ describe('createLiveContextUsageTracker', () => {
query.pending[1]!.reject(new Error('host not ready'));
await Promise.resolve();
await Promise.resolve();
- assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
+ assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]);
tracker.dispose();
});
@@ -355,7 +356,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
// Aiming, then leaving s1 clears its (never-landed) reading, then s2's
// lands; the stale s1 read resolving late must not overwrite it.
- assert.deepEqual(seen, [undefined, undefined, { usageTokens: 5_000, contextWindow: 128_000 }]);
+ assert.deepEqual(seen, [undefined, undefined, { completedAt: 1, usageTokens: 5_000, contextWindow: 128_000 }]);
tracker.dispose();
});
@@ -381,7 +382,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
assert.deepEqual(seen, [
undefined,
- { usageTokens: 79_436, contextWindow: 128_000 },
+ { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 },
undefined,
undefined,
]);
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index e8ecc9fb4d..14049ee31a 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -2430,7 +2430,7 @@ function AppShellContent({
activeModel={activeModel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
- latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)}
+ latestRequestUsage={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)}
onOpenContextUsage={() => commands.toggleTool('inspector')}
LiveContextUsageProbe={LiveContextUsageProbe}
contextUsageSessionId={ownerActiveId}
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
index ce32b827fe..2078f8c190 100644
--- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
@@ -17,27 +17,9 @@
* under the License.
*/
-/**
- * The session's latest provider-counted request, or nothing.
- *
- * A token count belongs to one request on one route: it is a number in that
- * model's tokenizer, and it is only the session's latest if nothing newer
- * exists. The runtime enforces both when it reads an anchor back, refusing one
- * whose run header names another model or connection. A control that shows the
- * number has to enforce the same two facts or it will display a precise-looking
- * figure about a request the user is not making — model A's tokens against
- * model B's window, or a historical range's usage presented as current.
- *
- * So this refuses rather than approximates, and the three refusals are the
- * three normal states that break the pairing:
- *
- * - the loaded transcript range is not the session tail, so a newer request may
- * exist that this range cannot see;
- * - the newest usage row carries no anchor, which is what manual `/compact`
- * writes, so the scan continues past it exactly as the runtime's does;
- * - the anchor names a different route than the active one, or names none at
- * all because it was written before anchors carried their route.
- */
+import type { ContextUsageReading } from '@maka/ui';
+import type { LiveContextUsage } from './live-context-usage.js';
+
export interface LatestRequestUsageAnchor {
inputTokens: number;
outputTokens?: number;
@@ -45,22 +27,77 @@ export interface LatestRequestUsageAnchor {
connectionId?: string;
}
+export interface LatestRequestUsageRow {
+ readonly type: string;
+ readonly ts?: number;
+ readonly kind?: string;
+ readonly lastRequestAnchor?: LatestRequestUsageAnchor;
+}
+
+export type LatestRequestUsage =
+ | { readonly kind: 'tokens'; readonly tokens: number }
+ | { readonly kind: 'compacted'; readonly at?: number }
+ | undefined;
+
+/**
+ * Read the newest route-matching measurement or compaction from the session tail.
+ * Anchorless usage rows (including manual compaction usage) carry no measurement.
+ * A compaction invalidates earlier measurements until a later request settles.
+ */
export function selectLatestRequestUsage(
- messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
+ messages: readonly LatestRequestUsageRow[],
model: string | undefined,
route: { llmConnectionId?: string } | undefined,
-): number | undefined {
+): LatestRequestUsage {
const connectionId = route?.llmConnectionId;
- if (model === undefined || connectionId === undefined) return undefined;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
+ // Ledger order decides whether the latest anchor has been superseded.
+ if (message?.type === 'system_note' && message.kind === 'context_compacted') {
+ return { kind: 'compacted', ...(message.ts !== undefined ? { at: message.ts } : {}) };
+ }
if (message?.type !== 'token_usage') continue;
const anchor = message.lastRequestAnchor;
if (!anchor) continue;
+ if (model === undefined || connectionId === undefined) return undefined;
if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
- return anchor.inputTokens + output;
+ return {
+ kind: 'tokens',
+ tokens: anchor.inputTokens + output,
+ };
}
return undefined;
}
+
+/**
+ * Prefer the per-request snapshot to the turn-end anchor. A known compaction
+ * suppresses snapshots that cannot be shown to postdate its transcript note.
+ * This preserves the existing timestamp policy; the note may be recorded later
+ * than the actual fold, so it is not a causal checkpoint identifier.
+ */
+export function resolveContextUsage(input: {
+ readonly latestRequestUsage: LatestRequestUsage;
+ readonly live?: LiveContextUsage;
+}): ContextUsageReading {
+ const { latestRequestUsage, live } = input;
+ if (
+ latestRequestUsage?.kind === 'compacted' &&
+ (live?.completedAt === undefined ||
+ latestRequestUsage.at === undefined ||
+ latestRequestUsage.at >= live.completedAt)
+ ) {
+ return { kind: 'stale', reason: 'compaction' };
+ }
+ if (live) {
+ return {
+ kind: 'measured',
+ tokens: live.usageTokens,
+ ...(live.contextWindow !== undefined ? { meteredWindow: live.contextWindow } : {}),
+ };
+ }
+ if (latestRequestUsage?.kind === 'tokens')
+ return { kind: 'measured', tokens: latestRequestUsage.tokens };
+ return { kind: 'unavailable' };
+}
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
index e6852c37e2..17f85d7c5b 100644
--- a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
@@ -43,6 +43,14 @@ export interface LiveContextUsage {
readonly usageTokens: number;
/** The window the request was metered against, frozen at call time. */
readonly contextWindow?: number;
+ /**
+ * When that request settled, on the Host's clock — the same clock the
+ * session's own transcript rows carry, so a reader can tell whether this
+ * snapshot predates a compaction boundary it already knows about. Without
+ * it, a snapshot that a fold has replaced is indistinguishable from one
+ * taken after it.
+ */
+ readonly completedAt?: number;
}
/**
@@ -55,6 +63,10 @@ export interface LiveContextUsage {
* numerator by the same row's denominator exactly as the inspector's bar
* does — a window from the live catalog could disagree with the metered
* request, while a user-declared override still wins by design.
+ *
+ * The settlement time rides along for the same reason the window does: it
+ * belongs to this request alone, and a reader comparing this reading against a
+ * compaction boundary has no other row to get it from.
*/
export function liveContextUsageFromDiagnostics(
diagnostics: ContextDiagnosticsResult | undefined,
@@ -74,6 +86,7 @@ export function liveContextUsageFromDiagnostics(
...(diagnostics.contextWindow !== undefined
? { contextWindow: diagnostics.contextWindow }
: {}),
+ completedAt: diagnostics.completedAt,
};
}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 91b55b8256..467d6f9121 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -30,7 +30,13 @@ import {
UserQuestionPrompt,
} from '@maka/ui';
import type { ComposerHandle } from '@maka/ui';
-export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js';
+import {
+ resolveContextUsage,
+ selectLatestRequestUsage,
+ type LatestRequestUsage,
+} from './application/contracts/session-inspector/latest-request-usage.js';
+export { selectLatestRequestUsage };
+import type { LiveContextUsage } from './application/contracts/session-inspector/live-context-usage.js';
import { useComposerMentionsContext } from './composer-mentions.js';
import {
readNewTaskReloadDraft,
@@ -122,14 +128,14 @@ interface ChatComposerRegionProps
boundaryUnreadableNotice?: BoundaryUnreadableNotice;
/**
* Tokens the provider counted for the session's latest request on the active
- * route, or nothing when that cannot be established. Resolved by the owner,
- * which knows the transcript range and the route; this control never derives
- * it from the rendered slice. This is the per-turn anchor: it moves when a
- * turn's usage record lands. `LiveContextUsageProbe` overlays the
- * per-settled-request snapshot (#4717) whenever that snapshot can vouch for
- * the same route, and this value is the fallback when it cannot.
+ * route, or nothing when that cannot be established, or a compaction
+ * boundary that superseded it. Resolved by the owner, which knows the
+ * transcript range and the route; this control never derives it from the
+ * rendered slice. `LiveContextUsageProbe` overlays the per-settled-request
+ * snapshot (#4717) whenever that snapshot is the newer answer to the same
+ * question, and this value is the fallback when it is not.
*/
- latestRequestUsageTokens?: number;
+ latestRequestUsage?: LatestRequestUsage;
onOpenContextUsage(): void;
/**
* The live overlay for the gauge (#4717), injected rather than imported:
@@ -149,7 +155,7 @@ interface ChatComposerRegionProps
* ceiling.
*/
children: (
- usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ usage: LiveContextUsage | undefined,
) => ReactNode;
}>;
directoryComposerProps: Pick<
@@ -175,7 +181,7 @@ export function ChatComposerRegion({
respondToUserForm,
stop,
boundaryUnreadableNotice,
- latestRequestUsageTokens,
+ latestRequestUsage,
onOpenContextUsage,
LiveContextUsageProbe,
directoryComposerProps,
@@ -196,14 +202,6 @@ export function ChatComposerRegion({
choice.model === composerRest.activeModel,
)
: undefined;
- const contextUsage = activeId
- ? {
- usageTokens: latestRequestUsageTokens,
- declaredContextWindow: activeModelChoice?.declaredContextWindow,
- metadataContextWindow: activeModelChoice?.contextWindow,
- onOpen: onOpenContextUsage,
- }
- : undefined;
const previousNewTaskDraftKey = useRef(newTaskDraftKey);
useLayoutEffect(() => {
const previous = previousNewTaskDraftKey.current;
@@ -265,47 +263,55 @@ export function ChatComposerRegion({
// — when mounted — can feed it the per-settled-request snapshot (#4717), and
// the anchor prop remains the reading it falls back to.
const renderComposer = (
- liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
- ) => (
-
- {(goalProjection) => (
-
- )}
-
- );
+ liveContextUsage: LiveContextUsage | undefined,
+ ) => {
+ // One question, two answers, and a fold can make the finer one stale: the
+ // snapshot wins when it landed after the boundary, and the boundary wins
+ // when it did not.
+ const reading = resolveContextUsage({ latestRequestUsage, live: liveContextUsage });
+ const contextUsage = activeId
+ ? {
+ reading,
+ declaredContextWindow: activeModelChoice?.declaredContextWindow,
+ metadataContextWindow: activeModelChoice?.contextWindow,
+ onOpen: onOpenContextUsage,
+ }
+ : undefined;
+ return (
+
+ {(goalProjection) => (
+
+ )}
+
+ );
+ };
return (
<>
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index 03d0076a7a..9e8bdfa4a7 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -22,7 +22,10 @@ import { ChatSurfaceLayout, UserQuestionPrompt, MakaWordmark, useUiLocale, type
import { Button, IconButton } from '@astryxdesign/core';
import { ChevronDown, PictureInPicture2, Undo2, X } from '@maka/ui/icons';
import { useLiveContextUsage } from '../../../application/contracts/session-inspector/use-live-context-usage.js';
-import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js';
+import {
+ resolveContextUsage,
+ selectLatestRequestUsage,
+} from '../../../application/contracts/session-inspector/latest-request-usage.js';
import { WorkHubProgressCard } from './workhub-progress-card.js';
import { WorkHubComposer } from './workhub-composer.js';
import { WorkHubConversation } from './workhub-conversation.js';
@@ -98,10 +101,19 @@ export function WorkHubRoot() {
);
const thinkingLevels = newWorkModelChoice?.thinkingLevels ?? [];
const liveContextUsage = useLiveContextUsage({ inspector: services.inspector, sessionId: controller.sessionId, model: session?.model, providerType: coordinationModelChoice?.providerType });
+ const contextUsageReading = useMemo(
+ () =>
+ resolveContextUsage({
+ latestRequestUsage: selectLatestRequestUsage(transcript.messages, session?.model, session),
+ live: liveContextUsage,
+ }),
+ [transcript.messages, session, liveContextUsage],
+ );
const thinkingLevel = controller.newWorkDefaults.thinkingLevel &&
thinkingLevels.includes(controller.newWorkDefaults.thinkingLevel)
? controller.newWorkDefaults.thinkingLevel
: undefined;
+
const locale = useUiLocale();
const t = workHubLiveCopy[locale];
const shortcutLabel = navigator.platform.toLowerCase().includes('mac') ? '⌘⇧K' : 'Ctrl+Shift+K';
@@ -401,9 +413,8 @@ export function WorkHubRoot() {
: undefined}
modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined}
contextUsage={session ? {
- usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, session.model, session),
+ reading: contextUsageReading,
declaredContextWindow: coordinationModelChoice?.declaredContextWindow,
- meteredContextWindow: liveContextUsage?.contextWindow,
metadataContextWindow: coordinationModelChoice?.contextWindow,
onOpen: () => call(services.presentation.openUsage()),
} : undefined}
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 8a31e20511..455e23860c 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -2492,7 +2492,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = {
turns={12}
composer={{
contextUsage: {
- usageTokens: 37_000,
+ reading: { kind: 'measured', tokens: 37_000 },
declaredContextWindow: 100_000,
onOpen: noop,
},
diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx
index 9f6c229f40..f11cb531fe 100644
--- a/packages/ui/src/__tests__/composer-context-usage.test.tsx
+++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx
@@ -23,6 +23,7 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { Composer } from '../composer.js';
+import type { ContextUsageReading } from '../context-usage-reading.js';
import { LocaleProvider } from '../locale-context.js';
test('the context usage action opens its host trace surface', async () => {
@@ -49,7 +50,7 @@ test('the context usage action opens its host trace surface', async () => {
await act(() => root.render(
{ opened = true; } }}
+ contextUsage={{ reading: { kind: 'unavailable' }, onOpen: () => { opened = true; } }}
onSend={() => undefined}
onStop={() => undefined}
/>
@@ -99,9 +100,8 @@ test('the context usage share resolves declared, then metered, then metadata win
const render = async (
contextUsage: {
- usageTokens?: number;
+ reading: ContextUsageReading;
declaredContextWindow?: number;
- meteredContextWindow?: number;
metadataContextWindow?: number;
},
) => {
@@ -125,9 +125,8 @@ test('the context usage share resolves declared, then metered, then metadata win
// The user's declaration wins over every reported window.
assert.equal(
await render({
- usageTokens: 40_000,
+ reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 },
declaredContextWindow: 100_000,
- meteredContextWindow: 80_000,
metadataContextWindow: 64_000,
}),
'40%',
@@ -135,13 +134,18 @@ test('the context usage share resolves declared, then metered, then metadata win
// The metered window was frozen against the same request as the tokens,
// so it outranks the catalog's metadata window.
assert.equal(
- await render({ usageTokens: 40_000, meteredContextWindow: 80_000, metadataContextWindow: 64_000 }),
+ await render({ reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 }, metadataContextWindow: 64_000 }),
'50%',
);
// Metadata is the fallback…
- assert.equal(await render({ usageTokens: 32_000, metadataContextWindow: 64_000 }), '50%');
+ assert.equal(await render({ reading: { kind: 'measured', tokens: 32_000 }, metadataContextWindow: 64_000 }), '50%');
// …and with no window at all the usage stands alone, no invented share.
- assert.equal(await render({ usageTokens: 40_000 }), 'Usage');
+ assert.equal(await render({ reading: { kind: 'measured', tokens: 40_000 } }), 'Usage');
+ // A superseded reading keeps the usage entry label even when a window is known.
+ assert.equal(await render({ reading: { kind: 'stale', reason: 'compaction' }, declaredContextWindow: 100_000 }), 'Usage');
+ // A later successful measurement restores the share in the same mounted control.
+ assert.equal(await render({ reading: { kind: 'measured', tokens: 10_000, meteredWindow: 100_000 } }), '10%');
+ assert.equal(await render({ reading: { kind: 'unavailable' }, declaredContextWindow: 100_000 }), 'Usage');
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx
index dcef7b5fb0..21fbdf4988 100644
--- a/packages/ui/src/composer.tsx
+++ b/packages/ui/src/composer.tsx
@@ -32,6 +32,7 @@ import {
type KeyboardEvent,
type ReactNode,
} from 'react';
+import type { ContextUsageReading } from './context-usage-reading.js';
import type { LucideIcon } from './icons.js';
import { useMountedRef } from './use-mounted-ref.js';
import { isAppleShortcutPlatform } from './utils.js';
@@ -458,14 +459,8 @@ export const Composer = forwardRef<
noModelHint?: string;
/** Read-only usage indicator for the active model's latest request. */
contextUsage?: {
- usageTokens?: number;
+ reading: ContextUsageReading;
declaredContextWindow?: number;
- /**
- * The window the usage number was metered against, frozen at call time.
- * When present it outranks the metadata window, so a live reading keeps
- * its numerator and denominator from the same request.
- */
- meteredContextWindow?: number;
metadataContextWindow?: number;
/** Open the Host-owned trace surface for this readout. */
onOpen(): void;
@@ -2493,13 +2488,15 @@ export const Composer = forwardRef<
});
function ContextUsageAction(props: {
- usageTokens?: number;
+ reading: ContextUsageReading;
declaredContextWindow?: number;
- meteredContextWindow?: number;
metadataContextWindow?: number;
onOpen(): void;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
+ const { reading } = props;
+ const usageTokens = reading.kind === 'measured' ? reading.tokens : undefined;
+ const meteredWindow = reading.kind === 'measured' ? reading.meteredWindow : undefined;
// A window from any source is enough to show a share, and the order is a
// claim about which window the number was earned against: the user's
// declaration first — it is the user's intent, and the only one that arms
@@ -2508,17 +2505,18 @@ function ContextUsageAction(props: {
// same request, and only then the model's reported metadata. With no window
// at all the usage stands on its own.
const window =
- props.declaredContextWindow ?? props.meteredContextWindow ?? props.metadataContextWindow;
- const label =
- props.usageTokens !== undefined && window !== undefined && window > 0
- ? `${Math.round((props.usageTokens / window) * 100)}%`
+ props.declaredContextWindow ?? meteredWindow ?? props.metadataContextWindow;
+ // Without a current measurement, keep the usage entry label.
+ const label = usageTokens !== undefined && window !== undefined && window > 0
+ ? `${Math.round((usageTokens / window) * 100)}%`
: copy.systemNotes.contextUsageLabel;
- const tooltip =
- props.usageTokens === undefined
+ const tooltip = reading.kind === 'stale'
+ ? copy.systemNotes.contextUsageCompacted
+ : usageTokens === undefined
? copy.systemNotes.contextUsageUnavailable
: window !== undefined && window > 0
- ? copy.systemNotes.contextUsageShare(props.usageTokens, window)
- : copy.systemNotes.contextUsageNoWindow(props.usageTokens);
+ ? copy.systemNotes.contextUsageShare(usageTokens, window)
+ : copy.systemNotes.contextUsageNoWindow(usageTokens);
return (
string;
contextUsageNoWindow: (used: number) => string;
contextUsageUnavailable: string;
+ contextUsageCompacted: string;
contextUsageOpen: string;
stepLimit: string;
};
@@ -541,6 +542,7 @@ const CONVERSATION_COPY = {
contextUsageNoWindow: (used) =>
`已用 ${formatCompactTokenCount(used)} token;上下文窗口上限未知`,
contextUsageUnavailable: '暂无用量数据',
+ contextUsageCompacted: '上下文已压缩,用量将在下一次请求完成后更新。',
contextUsageOpen: '打开用量追踪',
stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
},
@@ -667,6 +669,7 @@ const CONVERSATION_COPY = {
contextUsageNoWindow: (used) =>
`已用 ${formatCompactTokenCount(used)} token;上下文視窗上限未知`,
contextUsageUnavailable: '暫無用量資料',
+ contextUsageCompacted: '上下文已壓縮,用量將在下一次請求完成後更新。',
contextUsageOpen: '開啟用量追蹤',
stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。',
},
@@ -790,6 +793,8 @@ const CONVERSATION_COPY = {
contextUsageNoWindow: (used) =>
`This request used ${formatCompactTokenCount(used)} tokens; no context limit is available for this model.`,
contextUsageUnavailable: 'No usage data is available for this request.',
+ contextUsageCompacted:
+ 'Context has been compacted. Usage will update when the next request completes.',
contextUsageOpen: 'Open usage trace',
stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
},
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index b66a96e947..0a178e4528 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -37,6 +37,7 @@ export type {
export type { SessionMoveTarget } from './session-rail-context.js';
export * from './session-status-presentation.js';
export * from './composer-helpers.js';
+export type { ContextUsageReading } from './context-usage-reading.js';
export * from './conversation-copy.js';
export * from './shared-ui-copy.js';
export * from './skills-copy.js';