From 3312d92bbe2c06bd9ab518c7ba0b21ddaad8361c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 5 Sep 2026 15:56:39 +0800 Subject: [PATCH] fix(desktop): clear the composer gauge on target change, pair the frozen window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups to #4731, both confirmed against the merged head: - The tracker only cleared its reading when the target went away, so switching sessions or routes left the previous target's number on screen — indefinitely, when the new target's first read rejected, since a failed read deliberately keeps the last value standing. A changed target now clears the reading before the first read on the new one; re-aiming at the same target keeps the value, so a same-target refresh failure still does not flicker. The kept value is now only ever the current target's. - The probe boundary narrowed the live reading to usageTokens, so the gauge divided the snapshot's numerator by the live catalog's window while the inspector's bar used the snapshot's frozen one — the two indicators could disagree on the share, and with no metadata window the gauge dropped a percentage the snapshot could have supplied. The frozen contextWindow now travels with the tokens as meteredContextWindow, resolved after a declared override and before metadata. Refs #4717 Generated-by: Maka --- .../main/__tests__/live-context-usage.test.ts | 77 +++++++++++++++++-- .../src/renderer/chat-composer-region.tsx | 21 ++++- .../tools/inspector/live-context-usage.ts | 38 ++++++++- .../__tests__/composer-context-usage.test.tsx | 70 +++++++++++++++++ packages/ui/src/composer.tsx | 22 ++++-- 5 files changed, 209 insertions(+), 19 deletions(-) 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 f47a864b7a..0a7f0abd3d 100644 --- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts +++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts @@ -134,7 +134,9 @@ describe('createLiveContextUsageTracker', () => { assert.equal(query.pending.length, 1); query.pending[0]!.resolve(available()); await Promise.resolve(); - assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]); + // 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 }]); tracker.dispose(); }); @@ -183,6 +185,7 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.resolve(available({ inputTokens: 52_000 })); await Promise.resolve(); assert.deepEqual(seen, [ + undefined, { usageTokens: 40_000, contextWindow: 128_000 }, { usageTokens: 52_000, contextWindow: 128_000 }, ]); @@ -228,7 +231,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); query.pending[0]!.resolve(available({ inputTokens: 10_000 })); await Promise.resolve(); - assert.deepEqual(seen, [{ usageTokens: 60_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { usageTokens: 60_000, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -251,7 +254,63 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]); + tracker.dispose(); + }); + + it('clears the previous target’s reading before the first read on a new one', async () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const seen: unknown[] = []; + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: (usage) => seen.push(usage), + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + query.pending[0]!.resolve(available()); + await Promise.resolve(); + + // 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]); + + // …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]); + tracker.dispose(); + }); + + it('keeps the standing value when re-aimed at the same target', async () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const seen: unknown[] = []; + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: (usage) => seen.push(usage), + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + query.pending[0]!.resolve(available()); + await Promise.resolve(); + + // An identical re-aim is not a target change: the value still answers the + // question, so clearing it would only flicker. The re-read happens, and a + // failure keeps the value standing as always. + tracker.setTarget({ sessionId: 's1', route: { ...ROUTE } }); + assert.equal(query.pending.length, 2); + 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 }]); tracker.dispose(); }); @@ -271,7 +330,8 @@ describe('createLiveContextUsageTracker', () => { assert.equal(timer.scheduled, 0); query.pending[0]!.resolve(available()); await Promise.resolve(); - assert.deepEqual(seen, [undefined]); + // Once for aiming, once for the target going away. + assert.deepEqual(seen, [undefined, undefined]); tracker.dispose(); }); @@ -293,7 +353,9 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); query.pending[0]!.resolve(available({ inputTokens: 99_000 })); await Promise.resolve(); - assert.deepEqual(seen, [{ usageTokens: 5_000, contextWindow: 128_000 }]); + // 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 }]); tracker.dispose(); }); @@ -318,8 +380,10 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.resolve(available()); await Promise.resolve(); assert.deepEqual(seen, [ + undefined, { usageTokens: 79_436, contextWindow: 128_000 }, undefined, + undefined, ]); tracker.dispose(); }); @@ -341,6 +405,7 @@ describe('createLiveContextUsageTracker', () => { assert.equal(timer.scheduled, 0); query.pending[0]!.resolve(available()); await Promise.resolve(); - assert.deepEqual(seen, []); + // Only the aiming clear lands; the disposed tracker's read is dropped. + assert.deepEqual(seen, [undefined]); }); }); diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 2e0561989c..8114b1d9c3 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -133,7 +133,16 @@ interface ChatComposerRegionProps sessionId: string | undefined; model: string | undefined; providerType: string | undefined; - children: (usage: { readonly usageTokens: number } | undefined) => ReactNode; + /** + * The snapshot's reading as a PAIR: the metered tokens and the window the + * same request was metered against. Dropping the window would leave the + * gauge to divide the snapshot's numerator by whatever window the live + * catalog currently reports — one row's tokens against another row's + * ceiling. + */ + children: ( + usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, + ) => ReactNode; }>; directoryComposerProps: Pick< ComponentProps, @@ -296,14 +305,20 @@ export function ChatComposerRegion({ // The composer body as a function of the gauge's live reading, so the probe // — 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 } | undefined) => ( + const renderComposer = ( + liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, + ) => ( {(goalProjection) => ( { Object.assign(globalThis, original); } }); + +test('the context usage share resolves declared, then metered, then metadata window', async () => { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + + const render = async ( + contextUsage: { + usageTokens?: number; + declaredContextWindow?: number; + meteredContextWindow?: number; + metadataContextWindow?: number; + }, + ) => { + await act(() => root.render( + + undefined }} + onSend={() => undefined} + onStop={() => undefined} + /> + , + )); + const action = container.querySelector( + 'button[aria-label="Open usage trace"]', + ); + assert.ok(action); + return action.textContent?.trim(); + }; + + try { + // The user's declaration wins over every reported window. + assert.equal( + await render({ + usageTokens: 40_000, + declaredContextWindow: 100_000, + meteredContextWindow: 80_000, + metadataContextWindow: 64_000, + }), + '40%', + ); + // 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 }), + '50%', + ); + // Metadata is the fallback… + assert.equal(await render({ usageTokens: 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'); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 9ba7b50206..de7c8407e8 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -402,6 +402,12 @@ export const Composer = forwardRef< contextUsage?: { usageTokens?: number; 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; @@ -2247,16 +2253,20 @@ export const Composer = forwardRef< function ContextUsageAction(props: { usageTokens?: number; declaredContextWindow?: number; + meteredContextWindow?: number; metadataContextWindow?: number; onOpen(): void; }) { const copy = getConversationCopy(useUiLocale()).messages; - // A window from either source is enough to show a share: the user's - // declaration when there is one, otherwise the model's reported window. The - // distinction matters for the compaction threshold, which only a declaration - // arms, not for reading a number off the screen. With no window at all the - // usage stands on its own. - const window = props.declaredContextWindow ?? props.metadataContextWindow; + // 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 + // the compaction threshold — then the metered window frozen alongside the + // usage, so a live reading keeps its numerator and denominator from the + // 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)}%`