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 774ffb2f7a..d6070d55d7 100644
--- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts
+++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts
@@ -19,8 +19,13 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
+import { act, createElement, type ReactElement } from 'react';
+import { createRoot } from 'react-dom/client';
+import { parseHTML } from 'linkedom';
import type { SessionEvent } from '@maka/core/events';
import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
+import type { SessionInspectorService } from '../../renderer/application/contracts/session-inspector/service.js';
+import { useLiveContextUsageState } from '../../renderer/application/contracts/session-inspector/use-live-context-usage.js';
import {
createLiveContextUsageTracker,
liveContextUsageFromDiagnostics,
@@ -266,12 +271,14 @@ describe('createLiveContextUsageTracker', () => {
const timer = fakeTimer();
const query = scriptedQuery();
const seen: unknown[] = [];
+ let failures = 0;
const tracker = createLiveContextUsageTracker({
query: query.query,
delayMs: 400,
schedule: timer.schedule,
cancel: timer.cancel,
onChange: (usage) => seen.push(usage),
+ onReadFailure: () => { failures += 1; },
});
tracker.setTarget({ sessionId: 's1', route: ROUTE });
query.pending[0]!.resolve(available());
@@ -282,6 +289,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
+ assert.equal(failures, 1);
tracker.dispose();
});
@@ -436,3 +444,84 @@ describe('createLiveContextUsageTracker', () => {
assert.deepEqual(seen, [undefined]);
});
});
+
+it('reports pending rather than another target usage during a session switch', async () => {
+ const original = {
+ document: globalThis.document,
+ window: globalThis.window,
+ Element: globalThis.Element,
+ HTMLElement: globalThis.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
+ }).IS_REACT_ACT_ENVIRONMENT,
+ };
+ const { document, window } = parseHTML('
');
+ Object.assign(globalThis, {
+ document,
+ window,
+ Element: window.Element,
+ HTMLElement: window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ type ContextResult = Awaited>;
+ const pending: Array<{ sessionId: string; resolve: (value: ContextResult) => void }> = [];
+ const inspector: SessionInspectorService = {
+ trace: async () => { throw new Error('not used'); },
+ summary: async () => { throw new Error('not used'); },
+ context: (sessionId: string) =>
+ new Promise((resolve) => pending.push({ sessionId, resolve })),
+ subscribeSessionEvents: () => () => undefined,
+ subscribeUsageChanges: () => () => undefined,
+ };
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ let renders: Array<{
+ sessionId: string;
+ status: 'pending' | 'available' | 'unavailable';
+ usageTokens: number | undefined;
+ }> = [];
+ function Probe(props: { sessionId: string }): ReactElement {
+ const usage = useLiveContextUsageState({
+ inspector,
+ sessionId: props.sessionId,
+ model: ROUTE.model,
+ providerType: ROUTE.providerType,
+ });
+ renders.push({
+ sessionId: props.sessionId,
+ status: usage.status,
+ usageTokens: usage.status === 'available' ? usage.usage.usageTokens : undefined,
+ });
+ return createElement('span');
+ }
+
+ try {
+ await act(() => root.render(createElement(Probe, { sessionId: 's1' })));
+ await act(async () => {
+ pending[0]?.resolve({ ok: true, data: available({ inputTokens: 1_000 }) });
+ await Promise.resolve();
+ });
+ assert.equal(renders.at(-1)?.usageTokens, 1_000);
+
+ renders = [];
+ await act(() => root.render(createElement(Probe, { sessionId: 's2' })));
+ assert.equal(renders.at(-1)?.status, 'pending');
+ assert.equal(
+ renders.some((render) => render.usageTokens === 1_000),
+ false,
+ 'the old session usage must not appear in any render for the new target',
+ );
+ await act(async () => {
+ pending[1]?.resolve({
+ ok: true,
+ data: { status: 'unavailable', reason: 'no_completed_request' },
+ });
+ await Promise.resolve();
+ });
+ assert.equal(renders.at(-1)?.status, 'unavailable');
+ } finally {
+ await act(() => root.unmount());
+ Object.assign(globalThis, original);
+ }
+});
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 e94a6f08a2..7b19d7906a 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
@@ -136,6 +136,7 @@ export function createLiveContextUsageTracker(input: {
schedule: (callback: () => void, delayMs: number) => unknown;
cancel: (handle: unknown) => void;
onChange: (usage: LiveContextUsage | undefined) => void;
+ onReadFailure?: () => void;
}): LiveContextUsageTracker {
let target: LiveContextUsageTarget | undefined;
const coordinator = createRefreshReadCoordinator({
@@ -145,6 +146,7 @@ export function createLiveContextUsageTracker(input: {
if (!diagnostics || !target) return;
input.onChange(liveContextUsageFromDiagnostics(diagnostics, target.route));
},
+ onReadFailure: input.onReadFailure,
delayMs: input.delayMs,
schedule: (callback, delayMs) => {
const handle = input.schedule(callback, delayMs);
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
index 1933b5e423..dd3f48a096 100644
--- a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
@@ -25,6 +25,18 @@ import {
} from './live-context-usage.js';
import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js';
+interface TargetedLiveContextUsage {
+ readonly sessionId: string;
+ readonly model: string | undefined;
+ readonly providerType: string | undefined;
+ readonly state: LiveContextUsageState;
+}
+
+export type LiveContextUsageState =
+ | { readonly status: 'pending' }
+ | { readonly status: 'available'; readonly usage: LiveContextUsage }
+ | { readonly status: 'unavailable' };
+
/**
* The composer gauge's live reading (#4717).
*
@@ -34,19 +46,29 @@ import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js';
* 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.
+ * follows. The stateful form distinguishes a new target's first read from a
+ * settled refusal, so the composer does not present "no usage" while the Host
+ * is still answering. The value-only wrapper remains for consumers that only
+ * need the available reading.
*/
-export function useLiveContextUsage(input: {
+export function useLiveContextUsageState(input: {
readonly inspector: SessionInspectorService;
readonly sessionId: string | undefined;
readonly model: string | undefined;
readonly providerType: string | undefined;
-}): LiveContextUsage | undefined {
+}): LiveContextUsageState {
const { inspector } = input;
- const [usage, setUsage] = useState(undefined);
+ const [snapshot, setSnapshot] = useState(undefined);
const { sessionId, model, providerType } = input;
useEffect(() => {
+ if (sessionId === undefined) return;
+ let settingTarget = true;
+ const targetSnapshot = (state: LiveContextUsageState): TargetedLiveContextUsage => ({
+ sessionId,
+ model,
+ providerType,
+ state,
+ });
const tracker = createLiveContextUsageTracker({
query: async (targetSessionId) => {
const result = await inspector.context(targetSessionId);
@@ -56,21 +78,56 @@ export function useLiveContextUsage(input: {
delayMs: TRACE_REFRESH_DEBOUNCE_MS,
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
cancel: (handle) => clearTimeout(handle as ReturnType),
- onChange: setUsage,
+ onChange: (usage) => {
+ setSnapshot(
+ targetSnapshot(
+ settingTarget
+ ? { status: 'pending' }
+ : usage
+ ? { status: 'available', usage }
+ : { status: 'unavailable' },
+ ),
+ );
+ },
+ onReadFailure: () => {
+ setSnapshot((current) => {
+ if (
+ current?.sessionId === sessionId
+ && current.model === model
+ && current.providerType === providerType
+ && current.state.status === 'available'
+ ) {
+ return current;
+ }
+ return targetSnapshot({ status: 'unavailable' });
+ });
+ },
});
- tracker.setTarget(
- sessionId === undefined
- ? undefined
- : { sessionId, route: { model, providerType } },
- );
- const unsubscribe =
- sessionId === undefined
- ? undefined
- : inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event));
+ tracker.setTarget({ sessionId, route: { model, providerType } });
+ settingTarget = false;
+ const unsubscribe = inspector.subscribeSessionEvents(sessionId, (event) => tracker.observe(event));
return () => {
- unsubscribe?.();
+ unsubscribe();
tracker.dispose();
};
}, [inspector, sessionId, model, providerType]);
- return usage;
+ if (sessionId === undefined) return { status: 'unavailable' };
+ if (
+ snapshot?.sessionId !== sessionId
+ || snapshot.model !== model
+ || snapshot.providerType !== providerType
+ ) {
+ return { status: 'pending' };
+ }
+ return snapshot.state;
+}
+
+export function useLiveContextUsage(input: {
+ readonly inspector: SessionInspectorService;
+ readonly sessionId: string | undefined;
+ readonly model: string | undefined;
+ readonly providerType: string | undefined;
+}): LiveContextUsage | undefined {
+ const state = useLiveContextUsageState(input);
+ return state.status === 'available' ? state.usage : undefined;
}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 4d3f9db8fd..9a1fdc782a 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -150,6 +150,7 @@ interface ChatComposerRegionProps
*/
children: (
usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ usagePending: boolean,
) => ReactNode;
}>;
directoryComposerProps: Pick<
@@ -266,19 +267,25 @@ export function ChatComposerRegion({
// the anchor prop remains the reading it falls back to.
const renderComposer = (
liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
+ liveContextUsagePending: boolean,
) => (
{(goalProjection) => (
- {renderComposer}
+ {(usage, usagePending) => renderComposer(usage, usagePending)}
) : (
- renderComposer(undefined)
+ renderComposer(undefined, false)
)}
>
);
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
index e43864ef2d..33ef0ca1c2 100644
--- 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
@@ -20,7 +20,7 @@
import { useWorkbarServices } from '../../services-context.js';
import type { ReactElement, ReactNode } from 'react';
import type { LiveContextUsage } from '../../../../application/contracts/session-inspector/live-context-usage.js';
-import { useLiveContextUsage } from '../../../../application/contracts/session-inspector/use-live-context-usage.js';
+import { useLiveContextUsageState } from '../../../../application/contracts/session-inspector/use-live-context-usage.js';
/**
* Render-prop boundary for the composer context gauge (#4717).
@@ -28,22 +28,28 @@ import { useLiveContextUsage } from '../../../../application/contracts/session-i
* 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.
+ * render-prop consumer around the same composer. The pending bit lets the
+ * caller distinguish a new target's first read from a settled refusal;
+ * `undefined` usage still makes the caller try 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;
+ readonly children: (
+ usage: LiveContextUsage | undefined,
+ usagePending: boolean,
+ ) => ReactNode;
}): ReactElement {
const { inspector } = useWorkbarServices();
- const usage = useLiveContextUsage({
+ const usageState = useLiveContextUsageState({
inspector,
sessionId: props.sessionId,
model: props.model,
providerType: props.providerType,
});
- return <>{props.children(usage)}>;
+ return <>{props.children(
+ usageState.status === 'available' ? usageState.usage : undefined,
+ usageState.status === 'pending',
+ )}>;
}
diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css
index 438d0fa3df..3707b85c43 100644
--- a/apps/desktop/src/renderer/styles/chat-header.css
+++ b/apps/desktop/src/renderer/styles/chat-header.css
@@ -193,6 +193,7 @@
.maka-chat-layout {
overscroll-behavior: contain;
+ scrollbar-gutter: stable;
}
.maka-chat-shell {
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index c675f8c597..221a47ca28 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -302,6 +302,13 @@
align-items: center;
}
+/* Keep Astryx's disabled permission trigger from fading during a session
+ switch; it still exposes its disabled state and blocks activation. */
+.maka-composer-left-controls .permissionModeIcon [aria-disabled='true'],
+.maka-composer-left-controls .permissionModeIcon button:disabled {
+ opacity: 1;
+}
+
/* Cursor: product-wide native-cursor.css (maka.legacy) owns default vs pointer. */
/* Astryx sm list density: DropdownMenuItem + Selector options use
diff --git a/packages/core/src/refresh-read-coordinator.ts b/packages/core/src/refresh-read-coordinator.ts
index 3103d1f587..6ffe7ae7bf 100644
--- a/packages/core/src/refresh-read-coordinator.ts
+++ b/packages/core/src/refresh-read-coordinator.ts
@@ -41,6 +41,7 @@ export interface RefreshReadCoordinator {
export function createRefreshReadCoordinator(input: {
read: () => Promise;
apply: (result: T) => void;
+ onReadFailure?: () => void;
delayMs: number;
schedule: (callback: () => void, delayMs: number) => CancelScheduledRefresh;
}): RefreshReadCoordinator {
@@ -60,7 +61,10 @@ export function createRefreshReadCoordinator(input: {
if (readRevision !== revision) return;
input.apply(result);
},
- () => {},
+ () => {
+ if (readRevision !== revision) return;
+ input.onReadFailure?.();
+ },
);
};
diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx
index 9f6c229f40..1bbb10503f 100644
--- a/packages/ui/src/__tests__/composer-context-usage.test.tsx
+++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx
@@ -72,6 +72,44 @@ test('the context usage action opens its host trace surface', async () => {
await act(() => action.dispatchEvent(new window.Event('click', { bubbles: true })));
assert.equal(opened, true);
+
+ await act(() => root.render(
+
+ undefined }}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ ,
+ ));
+ const pendingAction = container.querySelector(
+ 'button[aria-label="Open usage trace"]',
+ );
+ assert.equal(pendingAction, action);
+ const value = pendingAction?.querySelector('.maka-context-usage-value');
+ assert.equal(value?.getAttribute('aria-busy'), 'true');
+ assert.equal(pendingAction?.textContent?.trim(), '--%');
+
+ await act(() => root.render(
+
+ undefined,
+ }}
+ onSend={() => undefined}
+ onStop={() => undefined}
+ />
+ ,
+ ));
+ const resolvedAction = container.querySelector(
+ 'button[aria-label="Open usage trace"]',
+ );
+ assert.equal(resolvedAction, action);
+ assert.equal(value?.getAttribute('aria-busy'), null);
+ assert.equal(resolvedAction?.textContent?.trim(), '40%');
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
index e1e4aa3090..51f8a46eb8 100644
--- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
+++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx
@@ -138,7 +138,7 @@ test('model switch availability has one priority-ordered contract', () => {
);
});
-test('the recovery handle opens the existing exact account-and-model picker', async () => {
+test('the model picker recovers and survives session changes', async () => {
const original = {
document: globalThis.document,
window: globalThis.window,
@@ -266,7 +266,7 @@ test('the recovery handle opens the existing exact account-and-model picker', as
,
));
+ const triggerAfterSessionSwitch = document.querySelector(
+ '.maka-model-switcher-trigger',
+ );
+ assert.ok(triggerAfterSessionSwitch, 'session changes must keep the model trigger mounted');
+ assert.equal(
+ document.querySelector('.maka-model-switcher-trigger [aria-expanded]')
+ ?.getAttribute('aria-readonly'),
+ null,
+ 'session changes must not create a transient read-only trigger',
+ );
await act(() => composer.current?.openModelPicker());
const selectedOption = document.querySelector(
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index d5c9f08c33..a5270556fc 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -175,9 +175,9 @@ export function ChatModelSwitcher(props: {
/**
* Selector has no controlled open prop, so recovery bumps this instead: the
* remount keyed on it opens the panel via `isDefaultOpen`, an entirely
- * documented surface. The key combines the Session id, this recovery nonce,
- * and the notice acknowledgement nonce. The Composer resets recovery on
- * Session changes so the new Session lands closed.
+ * documented surface. The key combines this recovery nonce and the notice
+ * acknowledgement nonce; the Selector itself stays mounted across Session
+ * changes so the trigger does not flicker or lose focus.
*/
openNonce?: number;
/** Force any open surface closed while an interaction prompt occludes the composer. */
@@ -377,7 +377,7 @@ export function ChatModelSwitcher(props: {
return (
0
+ ? `${Math.round((usageTokens / window) * 100)}%`
+ : undefined;
+ const hasShare = share !== undefined;
+ const hasUsage = usageTokens !== undefined;
+ const pending = props.pending && !hasShare && !hasUsage;
const label =
- props.usageTokens !== undefined && window !== undefined && window > 0
- ? `${Math.round((props.usageTokens / window) * 100)}%`
+ pending
+ ? '--%'
+ : hasShare
+ ? share
: copy.systemNotes.contextUsageLabel;
const tooltip =
- props.usageTokens === undefined
+ pending
+ ? copy.systemNotes.contextUsageOpen
+ : props.usageTokens === undefined
? copy.systemNotes.contextUsageUnavailable
: window !== undefined && window > 0
? copy.systemNotes.contextUsageShare(props.usageTokens, window)
@@ -2622,12 +2637,13 @@ function ContextUsageAction(props: {
}
label={copy.systemNotes.contextUsageOpen}
tooltip={tooltip}
onClick={props.onOpen}
>
- {label}
+ {label}
);
}
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 633f8957f4..41c4ab434e 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -43,6 +43,13 @@
.maka-model-wheel-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--muted-foreground); transform: scale(0.96); transform-origin: left center; transition: transform 180ms ease-out, color 180ms ease-out; }
.maka-model-wheel-option[data-active='true'] .maka-model-wheel-label { font-weight: 600; color: var(--foreground); transform: scale(1); }
.maka-model-wheel-viewport[aria-disabled='true'] { opacity: 0.5; overflow-y: hidden; }
+
+.maka-context-usage-value {
+ display: inline-block;
+ min-width: 3ch;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
@media (prefers-reduced-motion: reduce) { .maka-model-wheel-label { transition: none; } }
.maka-model-wheel-provider { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: var(--muted-foreground); }
.maka-model-wheel-check { position: absolute; right: 8px; top: 50%; translate: 0 -50%; color: var(--muted-foreground); }