Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/desktop/src/main/__tests__/live-context-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand All @@ -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();
});

Expand Down Expand Up @@ -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('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
Element: window.Element,
HTMLElement: window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
type ContextResult = Awaited<ReturnType<SessionInspectorService['context']>>;
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<ContextResult>((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);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand All @@ -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<LiveContextUsage | undefined>(undefined);
const [snapshot, setSnapshot] = useState<TargetedLiveContextUsage | undefined>(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);
Expand All @@ -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<typeof setTimeout>),
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;
}
19 changes: 13 additions & 6 deletions apps/desktop/src/renderer/chat-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ interface ChatComposerRegionProps
*/
children: (
usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
usagePending: boolean,
) => ReactNode;
}>;
directoryComposerProps: Pick<
Expand Down Expand Up @@ -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,
) => (
<ComposerGoalProjectionConsumer>
{(goalProjection) => (
<Composer
ref={composerRef}
{...composerRest}
contextUsage={contextUsage && liveContextUsage
contextUsage={contextUsage
? {
...contextUsage,
usageTokens: liveContextUsage.usageTokens,
meteredContextWindow: liveContextUsage.contextWindow,
...(liveContextUsage
? {
usageTokens: liveContextUsage.usageTokens,
meteredContextWindow: liveContextUsage.contextWindow,
}
: {}),
pending: liveContextUsagePending,
}
: contextUsage}
: undefined}
// AppShell carries staged attachments into both queued and steering
// follow-ups. Other Composer hosts remain gated by default because a
// text-only running-turn submission would leave attachments behind.
Expand Down Expand Up @@ -375,10 +382,10 @@ export function ChatComposerRegion({
model={composerRest.activeModel}
providerType={composerRest.activeProviderType}
>
{renderComposer}
{(usage, usagePending) => renderComposer(usage, usagePending)}
</LiveContextUsageProbe>
) : (
renderComposer(undefined)
renderComposer(undefined, false)
)}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,36 @@
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).
*
* 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',
)}</>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/styles/chat-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@

.maka-chat-layout {
overscroll-behavior: contain;
scrollbar-gutter: stable;
}

.maka-chat-shell {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/refresh-read-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface RefreshReadCoordinator {
export function createRefreshReadCoordinator<T>(input: {
read: () => Promise<T>;
apply: (result: T) => void;
onReadFailure?: () => void;
delayMs: number;
schedule: (callback: () => void, delayMs: number) => CancelScheduledRefresh;
}): RefreshReadCoordinator {
Expand All @@ -60,7 +61,10 @@ export function createRefreshReadCoordinator<T>(input: {
if (readRevision !== revision) return;
input.apply(result);
},
() => {},
() => {
if (readRevision !== revision) return;
input.onReadFailure?.();
},
);
};

Expand Down
Loading