Skip to content
Merged
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
16 changes: 16 additions & 0 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ async function createSession(page: Page, prompt: string) {
return { composer, sessionId: sessionId!, sidebar };
}

test('the composer usage action opens Task trace in the right workbar', async ({
accessibilityNarrativeWindow: page,
}) => {
const action = page.getByRole('button', { name: '打开用量追踪' });
await expect(action).toBeVisible();

await action.click();

const rightPanel = page.locator(
'.maka-session-workbar-panel[data-overlay][data-placement="right"]',
);
await expect(
rightPanel.locator('[data-maka-contract="session-inspector"]'),
).toBeVisible();
});

test('a collapsed workbar never flashes during the first send', async ({
window: page,
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ async function mountRegion(): Promise<{
children: createElement(AstryxLocaleProvider, {
children: createElement(ChatComposerRegion, {
composerRef: composer,
onOpenContextUsage: () => undefined,
directoryComposerProps: {},
directoryPickerEnabled: false,

Expand Down
21 changes: 8 additions & 13 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2931,7 +2931,7 @@ function AppShellContent({
respondToUserQuestion={respondToUserQuestion}
stop={stop}
directoryComposerProps={directoryComposerProps}
directoryPickerEnabled={!!(
directoryPickerEnabled={Boolean(
canStageComposerContext && directoryHostId && !revisionDraft
)}
// #646: Stop must be available for the WHOLE turn - the moment the
Expand Down Expand Up @@ -2984,22 +2984,21 @@ function AppShellContent({
? undefined
: attachFilePaths
}
modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined}
modelLabel={activeModelLabel ?? newChatModelLabel}
activeSession={activeSessionForView}
activeModelConnectionId={activeSessionForModelControls?.llmConnectionId}
activeModelConnectionSlug={activeSessionForModelControls?.llmConnectionSlug}
activeModel={activeModel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)}
onOpenContextUsage={() => workbar.commands.openTool('inspector')}
modelChoices={chatModelChoices}
modelSwitchHasHistory={modelSwitchHasHistory}
hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}
renderProviderMark={(type) => <ProviderBrandMark type={type} />}
modelSwitchAvailability={modelSwitchAvailability}
onModelChange={(input) => {
if (activeId) void setSessionModel(activeId, input);
}}
onModelChange={(input) => activeId ? void setSessionModel(activeId, input) : undefined}
activeThinkingLevels={activeThinkingLevels}
activeThinkingLevel={activeThinkingLevel}
onThinkingLevelChange={(level) => {
Expand Down Expand Up @@ -3044,8 +3043,8 @@ function AppShellContent({
}
onPermissionModeChange={
activeBoundarySurface.localInteractionAvailable
? async (mode) => {
await setPermissionMode(mode);
? async mode => {
await setPermissionMode(mode)
}
: undefined
}
Expand All @@ -3055,14 +3054,10 @@ function AppShellContent({
// a reason here would gray the row mid-click — the blink
// this control had. The rows repaint when the write lands.
planModeDisabledReason={modeChangeDisabledReason}
onPlanModeChange={(active) => {
void setPlanMode(active);
}}
onPlanModeChange={(active) => void setPlanMode(active)}
orchestrationMode={activeOrchestrationMode}
orchestrationModeDisabledReason={modeChangeDisabledReason}
onOrchestrationModeChange={(mode) => {
void setOrchestrationMode(mode);
}}
onOrchestrationModeChange={(mode) => void setOrchestrationMode(mode)}
goalDisabledReason={
activeStreamingLive || (activeId && turnActive)
? shellCopy.goalTurnActive
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/chat-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ interface ChatComposerRegionProps
* it from the rendered slice.
*/
latestRequestUsageTokens?: number;
onOpenContextUsage(): void;
directoryComposerProps: Pick<
ComponentProps<typeof Composer>,
'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory'
Expand Down Expand Up @@ -189,6 +190,7 @@ export function ChatComposerRegion({
stop,
boundaryUnreadableNotice,
latestRequestUsageTokens,
onOpenContextUsage,
directoryComposerProps,
directoryPickerEnabled,
...composerRest
Expand All @@ -211,6 +213,7 @@ export function ChatComposerRegion({
usageTokens: latestRequestUsageTokens,
declaredContextWindow: activeModelChoice?.declaredContextWindow,
metadataContextWindow: activeModelChoice?.contextWindow,
onOpen: onOpenContextUsage,
}
: undefined;
const previousNewTaskDraftKey = useRef(newTaskDraftKey);
Expand Down
14 changes: 0 additions & 14 deletions apps/desktop/src/renderer/styles/model-switcher.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,6 @@
max-width: 100%;
}

.maka-context-usage-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 4ch;
color: var(--muted-foreground);
font: var(--maka-text-supporting);
white-space: nowrap;
}

.maka-context-usage-indicator svg {
flex: 0 0 auto;
}

/* The composer footer's model and thinking pickers are ghost-button
DropdownMenus — the same toolbar primitive as + and permission, so their
resting, hover, focus, and disabled chrome all derive from the Astryx
Expand Down
79 changes: 79 additions & 0 deletions packages/ui/src/__tests__/composer-context-usage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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 assert from 'node:assert/strict';
import test from 'node:test';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { Composer } from '../composer.js';
import { LocaleProvider } from '../locale-context.js';

test('the context usage action opens its host trace surface', async () => {
const original = {
document: globalThis.document,
window: globalThis.window,
IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}).IS_REACT_ACT_ENVIRONMENT,
};
const { document, window } = parseHTML('<div id="root"></div>');
window.getComputedStyle = () => ({
direction: 'ltr',
writingMode: 'horizontal-tb',
getPropertyValue: () => '',
}) as unknown as CSSStyleDeclaration;
Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
let opened = false;

try {
await act(() => root.render(
<LocaleProvider locale="en">
<Composer
contextUsage={{ onOpen: () => { opened = true; } }}
onSend={() => undefined}
onStop={() => undefined}
/>
</LocaleProvider>,
));

const action = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open usage trace"]',
);
assert.ok(
action,
'context usage must be an action that opens Trace; do not render a read-only hand-written label',
);
assert.equal(
action.classList.contains('astryx-button'),
true,
'context usage must use Astryx Button; do not hand-write this control with raw JSX or custom control CSS',
);
assert.equal(action.textContent?.trim(), 'Usage');

await act(() => action.dispatchEvent(new window.Event('click', { bubbles: true })));
assert.equal(opened, true);
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
}
});
33 changes: 33 additions & 0 deletions packages/ui/src/__tests__/conversation-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ test('explains why folder-reference messages cannot be edited and resent', () =>
);
});

test('context usage explains missing data without exposing provider internals', () => {
assert.equal(
getConversationCopy('zh').messages.systemNotes.contextUsageUnavailable,
'暂无用量数据',
);
assert.equal(
getConversationCopy('en').messages.systemNotes.contextUsageUnavailable,
'No usage data is available for this request.',
);
});

test('context usage tooltip leads with the measured share', () => {
assert.equal(
getConversationCopy('zh').messages.systemNotes.contextUsageShare(12_345, 128_000),
'已用 12,345 / 128,000 token(10%)',
);
assert.equal(
getConversationCopy('en').messages.systemNotes.contextUsageShare(12_345, 128_000),
'This request used 12,345 / 128,000 tokens (10%).',
);
});

test('context usage tooltip keeps measured usage when the limit is unknown', () => {
assert.equal(
getConversationCopy('zh').messages.systemNotes.contextUsageNoWindow(12_345),
'已用 12,345 token;上下文上限未知',
);
assert.equal(
getConversationCopy('en').messages.systemNotes.contextUsageNoWindow(12_345),
'This request used 12,345 tokens; no context limit is available for this model.',
);
});

/**
* A subscription quota window can hand the runtime an hour-scale Retry-After;
* the banner must count down in humanized d/h/m/s units rather than a raw
Expand Down
34 changes: 20 additions & 14 deletions packages/ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ export const Composer = forwardRef<
usageTokens?: number;
declaredContextWindow?: number;
metadataContextWindow?: number;
/** Open the Host-owned trace surface for this readout. */
onOpen(): void;
};
/**
* Optional edit-and-resend banner above the composer. Desktop owns the
Expand Down Expand Up @@ -2142,7 +2144,7 @@ export const Composer = forwardRef<
onChange={props.onNewChatThinkingLevelChange}
/>
)}
{props.contextUsage ? <ContextUsageIndicator {...props.contextUsage} /> : null}
{props.contextUsage ? <ContextUsageAction {...props.contextUsage} /> : null}
</div>
{/* The project decides where a NEW chat starts, which makes it a
parameter of this send like the model beside it — so it sits
Expand Down Expand Up @@ -2242,10 +2244,11 @@ export const Composer = forwardRef<
);
});

function ContextUsageIndicator(props: {
function ContextUsageAction(props: {
usageTokens?: number;
declaredContextWindow?: number;
metadataContextWindow?: number;
onOpen(): void;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
// A window from either source is enough to show a share: the user's
Expand All @@ -2255,24 +2258,27 @@ function ContextUsageIndicator(props: {
// usage stands on its own.
const window = props.declaredContextWindow ?? props.metadataContextWindow;
const label =
props.usageTokens === undefined
? '—'
: window !== undefined && window > 0
? `${Math.round((props.usageTokens / window) * 100)}%`
: `${props.usageTokens} tok`;
props.usageTokens !== undefined && window !== undefined && window > 0
? `${Math.round((props.usageTokens / window) * 100)}%`
: copy.systemNotes.contextUsageLabel;
const tooltip =
props.usageTokens === undefined
? copy.systemNotes.contextUsageUnavailable
: window !== undefined && window > 0
? copy.systemNotes.contextUsageShare(props.usageTokens, window)
: copy.systemNotes.contextUsageNoWindow;
const indicator = (
<span className="maka-context-usage-indicator" aria-label={tooltip ?? label}>
<CircleGauge size={ICON_SIZE.meta} aria-hidden="true" />
<span>{label}</span>
</span>
: copy.systemNotes.contextUsageNoWindow(props.usageTokens);
return (
<UiButton
variant="ghost"
size="sm"
icon={<CircleGauge size={ICON_SIZE.meta} aria-hidden="true" />}
label={copy.systemNotes.contextUsageOpen}
tooltip={tooltip}
onClick={props.onOpen}
>
{label}
</UiButton>
);
return tooltip ? <Tooltip content={tooltip}>{indicator}</Tooltip> : indicator;
}

export type ComposerProps = ComponentProps<typeof Composer>;
22 changes: 15 additions & 7 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,11 @@ export interface ConversationCopy {
contextWindowOverrun: (used: number, declared: number) => string;
contextReportedWindowExceeded: (used: number, reported: number) => string;
contextOverflowAfterCompaction: string;
contextUsageLabel: string;
contextUsageShare: (used: number, window: number) => string;
contextUsageNoWindow: string;
contextUsageNoWindow: (used: number) => string;
contextUsageUnavailable: string;
contextUsageOpen: string;
stepLimit: string;
};
};
Expand Down Expand Up @@ -544,10 +546,13 @@ const CONVERSATION_COPY = {
`本次交换用了约 ${used} tokens,已超过该模型上报的窗口(${reported}),但供应商没有拒绝。你未声明窗口,Maka 因此不会主动压缩。在连接设置里声明一个窗口即可让它先行压缩。`,
contextOverflowAfterCompaction:
'已经压缩过历史,供应商仍然说这次请求太大。剩下的部分还包含系统提示、工具定义、摘要和最近的原文,缩短这条消息是你能控制的那一半。',
contextUsageLabel: '用量',
contextUsageShare: (used, window) =>
`${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} tokens`,
contextUsageNoWindow: '该模型没有窗口大小可用:未声明,模型也未上报',
contextUsageUnavailable: '供应商未报告用量',
`已用 ${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} token(${Math.round((used / window) * 100)}%)`,
contextUsageNoWindow: (used) =>
`已用 ${used.toLocaleString('zh-CN')} token;上下文上限未知`,
contextUsageUnavailable: '暂无用量数据',
contextUsageOpen: '打开用量追踪',
stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
},
},
Expand Down Expand Up @@ -718,10 +723,13 @@ const CONVERSATION_COPY = {
`This exchange used about ${used} tokens, past the ${reported} this model reports, and the provider accepted it without complaint. Nothing is declared, so Maka does not compact on its own. Declare a context window in the connection settings to have it compact first.`,
contextOverflowAfterCompaction:
'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.',
contextUsageLabel: 'Usage',
contextUsageShare: (used, window) =>
`${used.toLocaleString('en-US')} / ${window.toLocaleString('en-US')} tokens`,
contextUsageNoWindow: 'No context window size is available: none declared, none reported',
contextUsageUnavailable: 'The provider did not report usage',
`This request used ${used.toLocaleString('en-US')} / ${window.toLocaleString('en-US')} tokens (${Math.round((used / window) * 100)}%).`,
contextUsageNoWindow: (used) =>
`This request used ${used.toLocaleString('en-US')} tokens; no context limit is available for this model.`,
contextUsageUnavailable: 'No usage data is available for this request.',
contextUsageOpen: 'Open usage trace',
stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
},
},
Expand Down
Loading