diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 65fdc366da..9e6e89a02f 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -120,7 +120,6 @@ "src/renderer/session-message-settlement.ts", "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", - "src/renderer/session-trace-refresh.ts", "src/renderer/session-workspace-actions.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", @@ -273,7 +272,6 @@ "src/renderer/features/workbar/model/workbar-layout.ts -> src/renderer/browser-storage", "src/renderer/features/workbar/model/workbar-tabs.ts -> src/renderer/browser-storage", "src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx -> src/renderer/open-path", - "src/renderer/features/workbar/tools/inspector/use-session-trace.ts -> src/renderer/session-trace-refresh", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/model-connection-errors", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/session-copy-attempt", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/attachment-preflight", @@ -891,7 +889,7 @@ "react": 1 }, "importSpecifiers": 148, - "nonTriviaTokens": 15602 + "nonTriviaTokens": 15601 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -2217,15 +2215,6 @@ "@maka/core/sandbox-boundary": 1 } }, - "src/renderer/session-trace-refresh.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/session-workspace-actions.ts": { "bridgePaths": { "window.maka.sessions.queryCancelledMessages": 1 diff --git a/apps/desktop/src/main/__tests__/live-context-usage.test.ts b/apps/desktop/src/main/__tests__/live-context-usage.test.ts new file mode 100644 index 0000000000..f47a864b7a --- /dev/null +++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts @@ -0,0 +1,346 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; +import { + createLiveContextUsageTracker, + liveContextUsageFromDiagnostics, +} from '../../renderer/features/workbar/testing.js'; + +const ROUTE = { model: 'deepseek-v4-flash', providerType: 'deepseek' } as const; + +function available(overrides: Record = {}): ContextDiagnosticsResult { + return { + status: 'available', + providerId: 'deepseek', + modelId: 'deepseek-v4-flash', + completedAt: 1, + inputTokens: 79_436, + contextWindow: 128_000, + ...overrides, + } as ContextDiagnosticsResult; +} + +function event(type: SessionEvent['type']): SessionEvent { + return { type, id: `${type}-1`, turnId: 'turn-1', ts: 1 } as SessionEvent; +} + +/** A controllable stand-in for `setTimeout`, so the policy is testable. */ +function fakeTimer() { + const pending = new Map void>(); + let nextHandle = 1; + return { + schedule: (callback: () => void) => { + const handle = nextHandle++; + pending.set(handle, callback); + return handle; + }, + cancel: (handle: unknown) => { + pending.delete(handle as number); + }, + fire: () => { + const callbacks = [...pending.values()]; + pending.clear(); + for (const callback of callbacks) callback(); + }, + get scheduled() { + return pending.size; + }, + }; +} + +/** A manually resolved query, so tests control exactly when each read lands. */ +function scriptedQuery() { + const pending: { sessionId: string; resolve: (value: ContextDiagnosticsResult) => void; reject: (error: unknown) => void }[] = []; + return { + query: (sessionId: string) => + new Promise((resolve, reject) => { + pending.push({ sessionId, resolve, reject }); + }), + pending, + }; +} + +describe('liveContextUsageFromDiagnostics', () => { + it('maps a matching snapshot onto the gauge, window included', () => { + assert.deepEqual(liveContextUsageFromDiagnostics(available(), ROUTE), { + usageTokens: 79_436, + contextWindow: 128_000, + }); + }); + + it('refuses a snapshot from another model or provider', () => { + // A token count is one model's number in one tokenizer: model A's tokens + // against model B's window is a precise-looking lie. + assert.equal(liveContextUsageFromDiagnostics(available({ modelId: 'other' }), ROUTE), undefined); + assert.equal(liveContextUsageFromDiagnostics(available({ providerId: 'openai' }), ROUTE), undefined); + }); + + it('refuses when the composer has no settled route', () => { + assert.equal(liveContextUsageFromDiagnostics(available(), { providerType: 'deepseek' }), undefined); + assert.equal(liveContextUsageFromDiagnostics(available(), { model: 'deepseek-v4-flash' }), undefined); + }); + + it('refuses unavailable or unmetered snapshots', () => { + assert.equal( + liveContextUsageFromDiagnostics({ status: 'unavailable', reason: 'no_completed_request' }, ROUTE), + undefined, + ); + assert.equal(liveContextUsageFromDiagnostics(available({ inputTokens: undefined }), ROUTE), undefined); + assert.equal(liveContextUsageFromDiagnostics(available({ inputTokens: 0 }), ROUTE), undefined); + assert.equal(liveContextUsageFromDiagnostics(undefined, ROUTE), undefined); + }); + + it('stands alone without a window', () => { + assert.deepEqual( + liveContextUsageFromDiagnostics(available({ contextWindow: undefined }), ROUTE), + { usageTokens: 79_436 }, + ); + }); +}); + +describe('createLiveContextUsageTracker', () => { + it('reads immediately when aimed at a session', 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 }); + assert.equal(query.pending.length, 1); + query.pending[0]!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]); + tracker.dispose(); + }); + + it('reports nothing when there is no target', () => { + 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(undefined); + assert.equal(query.pending.length, 0); + assert.deepEqual(seen, [undefined]); + tracker.dispose(); + }); + + it('re-reads on ledger-changing events after the debounce, coalescing bursts', 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({ inputTokens: 40_000 })); + await Promise.resolve(); + + // A step settling emits a burst; the last event's state is the one worth + // reading, so the burst must collapse into one re-read, and only after + // the debounce. + tracker.observe(event('tool_start')); + tracker.observe(event('tool_result')); + tracker.observe(event('text_delta')); + assert.equal(timer.scheduled, 1); + assert.equal(query.pending.length, 1); + timer.fire(); + assert.equal(query.pending.length, 2); + query.pending[1]!.resolve(available({ inputTokens: 52_000 })); + await Promise.resolve(); + assert.deepEqual(seen, [ + { usageTokens: 40_000, contextWindow: 128_000 }, + { usageTokens: 52_000, contextWindow: 128_000 }, + ]); + tracker.dispose(); + }); + + it('ignores streaming deltas', () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: () => undefined, + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + tracker.observe(event('text_delta')); + tracker.observe(event('tool_output_delta')); + assert.equal(timer.scheduled, 0); + assert.equal(query.pending.length, 1); + tracker.dispose(); + }); + + it('discards a read that answers an older question', 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 }); + tracker.observe(event('token_usage')); + timer.fire(); + assert.equal(query.pending.length, 2); + // The newer read lands first; the older one must not overwrite it when it + // resolves late. + query.pending[1]!.resolve(available({ inputTokens: 60_000 })); + await Promise.resolve(); + query.pending[0]!.resolve(available({ inputTokens: 10_000 })); + await Promise.resolve(); + assert.deepEqual(seen, [{ usageTokens: 60_000, contextWindow: 128_000 }]); + tracker.dispose(); + }); + + it('keeps the last value when a read fails', 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(); + tracker.observe(event('tool_result')); + timer.fire(); + query.pending[1]!.reject(new Error('host not ready')); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]); + tracker.dispose(); + }); + + it('clears immediately and drops in-flight reads when the target goes away', 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 }); + tracker.setTarget(undefined); + assert.equal(timer.scheduled, 0); + query.pending[0]!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(seen, [undefined]); + tracker.dispose(); + }); + + it('drops a read in flight for a session the user has left', 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 }); + tracker.setTarget({ sessionId: 's2', route: ROUTE }); + assert.equal(query.pending.length, 2); + query.pending[1]!.resolve(available({ inputTokens: 5_000 })); + await Promise.resolve(); + query.pending[0]!.resolve(available({ inputTokens: 99_000 })); + await Promise.resolve(); + assert.deepEqual(seen, [{ usageTokens: 5_000, contextWindow: 128_000 }]); + tracker.dispose(); + }); + + it('re-evaluates the same session when the route changes', 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(); + // The user switched models: the snapshot still names the old route, so + // the gauge must fall back rather than wear another model's number. + tracker.setTarget({ sessionId: 's1', route: { model: 'qwen3', providerType: 'alibaba' } }); + assert.equal(query.pending.length, 2); + query.pending[1]!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(seen, [ + { usageTokens: 79_436, contextWindow: 128_000 }, + undefined, + ]); + tracker.dispose(); + }); + + it('cancels a scheduled refresh and drops in-flight reads on dispose', 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 }); + tracker.observe(event('tool_result')); + tracker.dispose(); + assert.equal(timer.scheduled, 0); + query.pending[0]!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(seen, []); + }); +}); diff --git a/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts b/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts index 40000b1d03..1177f642b7 100644 --- a/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts +++ b/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts @@ -23,7 +23,7 @@ import type { SessionEvent } from '@maka/core/events'; import { createTraceRefreshCoalescer, isTraceRelevantEvent, -} from '../../renderer/session-trace-refresh.js'; +} from '../../renderer/features/workbar/testing.js'; function event(type: SessionEvent['type'], extra: Record = {}): SessionEvent { return { type, id: `${type}-1`, turnId: 'turn-1', ts: 1, ...extra } as SessionEvent; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3e17e55038..8ecc4e7f93 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -29,7 +29,6 @@ import { type Dispatch, type SetStateAction, } from 'react'; -import type { ProjectRecord } from '@maka/core/project'; import type { FollowUpMode, InlineReference, @@ -2959,6 +2958,7 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} onOpenContextUsage={() => workbar.commands.openTool('inspector')} + LiveContextUsageProbe={workbar.LiveContextUsageProbe} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 1800d1035c..2e0561989c 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useLayoutEffect, useRef, type ComponentProps, type RefObject } from 'react'; +import { useLayoutEffect, useRef, type ComponentProps, type ComponentType, type ReactNode, type RefObject } from 'react'; import { Banner, Button, @@ -116,10 +116,25 @@ interface ChatComposerRegionProps * 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. + * 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. */ latestRequestUsageTokens?: number; onOpenContextUsage(): void; + /** + * The live overlay for the gauge (#4717), injected rather than imported: + * the subscription owns services that live behind the Workbar services + * context, and this region must stay loadable without it (the draft-handoff + * suite mounts it bare). Absent, the per-turn anchor alone drives the gauge. + */ + LiveContextUsageProbe?: ComponentType<{ + sessionId: string | undefined; + model: string | undefined; + providerType: string | undefined; + children: (usage: { readonly usageTokens: number } | undefined) => ReactNode; + }>; directoryComposerProps: Pick< ComponentProps, 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' @@ -194,6 +209,7 @@ export function ChatComposerRegion({ boundaryUnreadableNotice, latestRequestUsageTokens, onOpenContextUsage, + LiveContextUsageProbe, directoryComposerProps, directoryPickerEnabled, ...composerRest @@ -277,6 +293,41 @@ export function ChatComposerRegion({ ); }, [composerRef, newTaskDraftKey, newTaskSendPending]); + // 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) => ( + + {(goalProjection) => ( + + ); + return ( <>
@@ -330,33 +381,17 @@ export function ChatComposerRegion({ /> )}
- - {(goalProjection) => ( - + {LiveContextUsageProbe ? ( + + {renderComposer} + + ) : ( + renderComposer(undefined) + )} ); } diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 59662743ab..fafc628a2b 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -64,6 +64,7 @@ import { import { recoverOrphanedCompanionCopies } from '../tools/side-chat/quote-companion-core.js'; import { useSideConversationWorkspace } from '../tools/side-chat/use-side-conversation-workspace.js'; import { useWorkbarLayoutState } from './use-workbar-layout-state.js'; +import { LiveContextUsageProbe } from '../tools/inspector/live-context-usage-probe.js'; interface OpenToolOptions { initialPrompt?: string; @@ -102,6 +103,14 @@ export interface WorkbarController { host: WorkbarHostModel; commands: WorkbarControllerCommands; selectors: WorkbarControllerSelectors; + /** + * The composer context gauge's live overlay (#4717), handed to the shell on + * the controller so the shell gains no import edge to the inspector's + * subscription: the app shell is a debt-ratcheted legacy file, and every + * named import it adds is new debt the ratchet forbids. The probe's readers + * stay inside this feature; the shell only forwards the reference. + */ + readonly LiveContextUsageProbe: typeof LiveContextUsageProbe; } function assertNever(value: never): never { @@ -694,6 +703,7 @@ export function useWorkbarController( return { commands, + LiveContextUsageProbe, selectors: { rightCollapsed: layout.workbarCollapsed, hiddenSessionIds: hiddenCompanionForkIds, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index f13752a75d..e7f38293e8 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -40,6 +40,8 @@ export { usageRingArcs, } from './tools/inspector/session-inspector-panel.js'; export * from './tools/inspector/session-inspector-overview-model.js'; +export * from './tools/inspector/session-trace-refresh.js'; +export * from './tools/inspector/live-context-usage.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; export * from './tools/side-chat/quote-companion-context-compaction.js'; diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx new file mode 100644 index 0000000000..14f3a032b7 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ReactElement, ReactNode } from 'react'; +import type { LiveContextUsage } from './live-context-usage.js'; +import { useLiveContextUsage } from './use-live-context-usage.js'; + +/** + * Render-prop boundary for the composer context gauge (#4717). + * + * The live reading needs a subscription and state, and both live here — in + * the feature that owns the inspector's context snapshot — so the shell only + * renders the reading, the same division of labour as the goal projection's + * render-prop consumer around the same composer. `undefined` means the + * snapshot cannot vouch for the composer's active route; the caller falls + * back to the per-turn anchor. + */ +export function LiveContextUsageProbe(props: { + readonly sessionId: string | undefined; + readonly model: string | undefined; + readonly providerType: string | undefined; + readonly children: (usage: LiveContextUsage | undefined) => ReactNode; +}): ReactElement { + const usage = useLiveContextUsage({ + sessionId: props.sessionId, + model: props.model, + providerType: props.providerType, + }); + return <>{props.children(usage)}; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts new file mode 100644 index 0000000000..ce7594c37b --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionEvent } from '@maka/core/events'; +import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; +import { createTraceRefreshCoalescer, type TraceRefreshCoalescer } from './session-trace-refresh.js'; + +/** + * What the composer is about to send on, and therefore the only route a + * context figure may describe (#4717). + * + * The gauge next to the model picker answers "how full is the window THIS + * model sees". A token count is one model's number in one tokenizer, so a + * snapshot from another route says nothing about that: model A's tokens + * against model B's window is a precise-looking lie, the same refusal + * `selectLatestRequestUsage` makes for the turn-end anchor. The snapshot has + * no connectionId — the Host records the provider, not the connection — so + * (providerType, modelId) is the tightest pair it can vouch for. + */ +export interface LiveContextRoute { + readonly model?: string; + readonly providerType?: string; +} + +/** The gauge's reading: the last settled request's prompt, and its ceiling. */ +export interface LiveContextUsage { + readonly usageTokens: number; + /** The window the request was metered against, frozen at call time. */ + readonly contextWindow?: number; +} + +/** + * Maps a context diagnostics snapshot onto the gauge, or refuses. + * + * "Used" is `inputTokens` — the prompt of the most recent settled request. + * That is deliberately NOT input+output: the snapshot does not carry output, + * and the inspector's context bar reads the same field, so both indicators in + * the window draw one number from one row and cannot disagree mid-turn. + */ +export function liveContextUsageFromDiagnostics( + diagnostics: ContextDiagnosticsResult | undefined, + route: LiveContextRoute, +): LiveContextUsage | undefined { + if (!diagnostics || diagnostics.status !== 'available') return undefined; + if (route.model === undefined || route.providerType === undefined) return undefined; + if (diagnostics.modelId !== route.model || diagnostics.providerId !== route.providerType) { + return undefined; + } + const inputTokens = diagnostics.inputTokens; + if (inputTokens === undefined || !Number.isFinite(inputTokens) || inputTokens <= 0) { + return undefined; + } + return { + usageTokens: inputTokens, + ...(diagnostics.contextWindow !== undefined + ? { contextWindow: diagnostics.contextWindow } + : {}), + }; +} + +export interface LiveContextUsageTarget { + readonly sessionId: string; + readonly route: LiveContextRoute; +} + +export interface LiveContextUsageTracker { + /** Aims the tracker at a session, or at nothing. Reads immediately. */ + setTarget(target: LiveContextUsageTarget | undefined): void; + /** Records a live event; schedules a re-read when it can change the snapshot. */ + observe(event: SessionEvent): void; + /** Drops any scheduled or in-flight read; the last reported value stands. */ + dispose(): void; +} + +/** + * Keeps the composer gauge on the per-request snapshot rather than the + * per-turn anchor. + * + * The Host seals `latest_context` when each provider request settles, so the + * finest honest granularity is "after each step" — token-level real-time + * mid-stream is impossible, since the provider only reports input tokens at + * completion (#4545 spells out why the alternatives were rejected). This + * tracker pulls that snapshot on the same signal the inspector uses — the + * trace-relevant live events, coalesced — with the three protections the + * inspector proved out: a revision counter drops reads that answer an older + * question, a failed read keeps the last value standing, and a target change + * discards whatever is still in flight. + * + * Framework-free on purpose: the timer and the query are injected, so the + * policy is testable without a DOM, and the hook in + * `use-live-context-usage.ts` is a thin React shell over this. + */ +export function createLiveContextUsageTracker(input: { + query: (sessionId: string) => Promise; + delayMs: number; + schedule: (callback: () => void, delayMs: number) => unknown; + cancel: (handle: unknown) => void; + onChange: (usage: LiveContextUsage | undefined) => void; +}): LiveContextUsageTracker { + let target: LiveContextUsageTarget | undefined; + let revision = 0; + const coalescer: TraceRefreshCoalescer = createTraceRefreshCoalescer({ + refresh: () => refresh(), + delayMs: input.delayMs, + schedule: input.schedule, + cancel: input.cancel, + }); + + function refresh(): void { + const current = target; + if (!current) return; + const readRevision = ++revision; + void input.query(current.sessionId).then( + (diagnostics) => { + if (readRevision !== revision) return; + input.onChange(liveContextUsageFromDiagnostics(diagnostics, current.route)); + }, + () => { + // A failed read leaves the last value standing: it is still the newest + // answer anyone has, and blanking it would report "no usage" for a + // read that simply failed. + }, + ); + } + + return { + setTarget(next) { + // Any target change — another session, another route, or none — makes + // the current reading unanswerable until the next read lands, and + // invalidates every read already in flight. + revision += 1; + target = next; + coalescer.cancel(); + if (!next) { + input.onChange(undefined); + return; + } + refresh(); + }, + observe(event) { + coalescer.observe(event); + }, + dispose() { + revision += 1; + target = undefined; + coalescer.cancel(); + }, + }; +} diff --git a/apps/desktop/src/renderer/session-trace-refresh.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts similarity index 96% rename from apps/desktop/src/renderer/session-trace-refresh.ts rename to apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts index 68a1a59b64..9d9d538fbd 100644 --- a/apps/desktop/src/renderer/session-trace-refresh.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts @@ -19,6 +19,9 @@ import type { SessionEvent } from '@maka/core/events'; +/** Long enough to absorb a turn's closing burst, short enough to feel live. */ +export const TRACE_REFRESH_DEBOUNCE_MS = 400; + /** * When a live session's trace is worth re-reading (#1625). * diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts new file mode 100644 index 0000000000..a8e9ef4d0f --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { useWorkbarServices } from '../../services-context.js'; +import { + createLiveContextUsageTracker, + type LiveContextUsage, +} from './live-context-usage.js'; +import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js'; + +/** + * The composer gauge's live reading (#4717). + * + * The gauge used to wait for the turn-end `token_usage` record, so a long + * agentic turn — exactly when context grows fastest — showed the previous + * turn's number throughout. The Host seals a latest-context snapshot at every + * settled provider request, and this hook keeps the gauge on that snapshot: + * an immediate read when the target changes, then a debounced re-read on each + * trace-relevant live event, the same signal the inspector's context bar + * follows. When the snapshot cannot vouch for the composer's active route the + * hook says nothing, and the caller falls back to the per-turn anchor. + */ +export function useLiveContextUsage(input: { + readonly sessionId: string | undefined; + readonly model: string | undefined; + readonly providerType: string | undefined; +}): LiveContextUsage | undefined { + const { inspector } = useWorkbarServices(); + const [usage, setUsage] = useState(undefined); + const { sessionId, model, providerType } = input; + useEffect(() => { + const tracker = createLiveContextUsageTracker({ + query: async (targetSessionId) => { + const result = await inspector.context(targetSessionId); + if (!result.ok) throw new Error(result.error.message); + return result.data; + }, + delayMs: TRACE_REFRESH_DEBOUNCE_MS, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancel: (handle) => clearTimeout(handle as ReturnType), + onChange: setUsage, + }); + tracker.setTarget( + sessionId === undefined + ? undefined + : { sessionId, route: { model, providerType } }, + ); + const unsubscribe = + sessionId === undefined + ? undefined + : inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event)); + return () => { + unsubscribe?.(); + tracker.dispose(); + }; + }, [inspector, sessionId, model, providerType]); + return usage; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts index 5632085f8f..92df88050c 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts @@ -33,7 +33,8 @@ import type { import { createRefreshCoalescer, createTraceRefreshCoalescer, -} from '../../../../session-trace-refresh.js'; + TRACE_REFRESH_DEBOUNCE_MS, +} from './session-trace-refresh.js'; import { useWorkbarServices } from '../../services-context.js'; interface SessionTraceState { @@ -62,9 +63,6 @@ interface SessionTraceSnapshot extends Omit { const EMPTY_STATE: SessionTraceState = { loading: false }; const EMPTY_SNAPSHOT: SessionTraceSnapshot = { loading: false }; -/** Long enough to absorb a turn's closing burst, short enough to feel live. */ -export const TRACE_REFRESH_DEBOUNCE_MS = 400; - /** * Reads the per-session causal trace (#1625). * diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 8f906c0cb8..e4e5f0f362 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 246 files — blocker 0, reimplementation 0, polish 1, aligned 245. +**Totals:** 247 files — blocker 0, reimplementation 0, polish 1, aligned 246. ## Exclusions (explicit) @@ -79,6 +79,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned | +| `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner | aligned — uses Astryx (Banner) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 2289173296..279c0dc561 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -50,6 +50,7 @@ apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx +apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx