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: { ); } 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); }