From 4086c5e4d1eb26f76f88f3f414d7161f0a4c11e1 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 03:27:22 +0800 Subject: [PATCH 1/6] fix(desktop): open usage trace from composer Generated-by: Codex --- apps/desktop/e2e/session-workbar.spec.ts | 16 +++++ ...chat-composer-region-draft-handoff.test.ts | 1 + apps/desktop/src/renderer/app-shell.tsx | 1 + .../src/renderer/chat-composer-region.tsx | 3 + .../src/renderer/styles/model-switcher.css | 14 ---- .../__tests__/composer-context-usage.test.tsx | 71 +++++++++++++++++++ packages/ui/src/composer.tsx | 24 ++++--- packages/ui/src/conversation-copy.ts | 3 + 8 files changed, 111 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/__tests__/composer-context-usage.test.tsx diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index f6bee3f5ca..ae6b329e54 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -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, }) => { diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 907c24243d..89f9847a1e 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -111,6 +111,7 @@ async function mountRegion(): Promise<{ children: createElement(AstryxLocaleProvider, { children: createElement(ChatComposerRegion, { composerRef: composer, + onOpenContextUsage: () => undefined, directoryComposerProps: {}, directoryPickerEnabled: false, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0a685bbab4..42cdc18b6b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2992,6 +2992,7 @@ function AppShellContent({ activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} + onOpenContextUsage={() => workbar.commands.openTool('inspector', 'right')} 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 1a70a0e022..e526a900c6 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -117,6 +117,7 @@ interface ChatComposerRegionProps * it from the rendered slice. */ latestRequestUsageTokens?: number; + onOpenContextUsage(): void; directoryComposerProps: Pick< ComponentProps, 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' @@ -189,6 +190,7 @@ export function ChatComposerRegion({ stop, boundaryUnreadableNotice, latestRequestUsageTokens, + onOpenContextUsage, directoryComposerProps, directoryPickerEnabled, ...composerRest @@ -211,6 +213,7 @@ export function ChatComposerRegion({ usageTokens: latestRequestUsageTokens, declaredContextWindow: activeModelChoice?.declaredContextWindow, metadataContextWindow: activeModelChoice?.contextWindow, + onOpen: onOpenContextUsage, } : undefined; const previousNewTaskDraftKey = useRef(newTaskDraftKey); diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css index 253304bf1f..06b95b61a2 100644 --- a/apps/desktop/src/renderer/styles/model-switcher.css +++ b/apps/desktop/src/renderer/styles/model-switcher.css @@ -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 diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx new file mode 100644 index 0000000000..dd3d60c9fa --- /dev/null +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -0,0 +1,71 @@ +/* + * 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('
'); + 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( + + { opened = true; } }} + onSend={() => undefined} + onStop={() => undefined} + /> + , + )); + + const action = container.querySelector( + 'button[aria-label="Open usage trace"]', + ); + assert.ok(action, 'usage is exposed as an Astryx action'); + assert.equal(action.classList.contains('astryx-button'), true); + + await act(() => action.dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.equal(opened, true); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 5782a857ea..9f2e88f897 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -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 @@ -2142,7 +2144,7 @@ export const Composer = forwardRef< onChange={props.onNewChatThinkingLevelChange} /> )} - {props.contextUsage ? : null} + {props.contextUsage ? : null} {/* The project decides where a NEW chat starts, which makes it a parameter of this send like the model beside it — so it sits @@ -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 @@ -2266,13 +2269,18 @@ function ContextUsageIndicator(props: { : window !== undefined && window > 0 ? copy.systemNotes.contextUsageShare(props.usageTokens, window) : copy.systemNotes.contextUsageNoWindow; - const indicator = ( - - + return ( + ); - return tooltip ? {indicator} : indicator; } export type ComposerProps = ComponentProps; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index e9b6f6dc91..2a97bc9af1 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -319,6 +319,7 @@ export interface ConversationCopy { contextUsageShare: (used: number, window: number) => string; contextUsageNoWindow: string; contextUsageUnavailable: string; + contextUsageOpen: string; stepLimit: string; }; }; @@ -548,6 +549,7 @@ const CONVERSATION_COPY = { `${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} tokens`, contextUsageNoWindow: '该模型没有窗口大小可用:未声明,模型也未上报', contextUsageUnavailable: '供应商未报告用量', + contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, }, @@ -722,6 +724,7 @@ const CONVERSATION_COPY = { `${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', + contextUsageOpen: 'Open usage trace', stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', }, }, From 1ecf9c6a45dc407bab4a17bd8c45e5a3a46faa71 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 03:38:47 +0800 Subject: [PATCH 2/6] test(ui): reject hand-written Astryx controls Generated-by: Codex --- .../__tests__/composer-context-usage.test.tsx | 11 +++- scripts/check-astryx-surface-inventory.mjs | 15 ++++- scripts/generate-astryx-surface-inventory.mjs | 29 ++++++++++ ...generate-astryx-surface-inventory.test.mjs | 58 +++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index dd3d60c9fa..939489ae3b 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -59,8 +59,15 @@ test('the context usage action opens its host trace surface', async () => { const action = container.querySelector( 'button[aria-label="Open usage trace"]', ); - assert.ok(action, 'usage is exposed as an Astryx action'); - assert.equal(action.classList.contains('astryx-button'), true); + 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', + ); await act(() => action.dispatchEvent(new window.Event('click', { bubbles: true }))); assert.equal(opened, true); diff --git a/scripts/check-astryx-surface-inventory.mjs b/scripts/check-astryx-surface-inventory.mjs index 924df12e9b..94db4ed462 100644 --- a/scripts/check-astryx-surface-inventory.mjs +++ b/scripts/check-astryx-surface-inventory.mjs @@ -31,11 +31,23 @@ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { renderAstryxSurfaceInventory } from './generate-astryx-surface-inventory.mjs'; +import { + assertNoAstryxBlockers, + renderAstryxSurfaceInventory, +} from './generate-astryx-surface-inventory.mjs'; const root = join(fileURLToPath(new URL('..', import.meta.url))); const pathsFile = join(root, 'docs/astryx-surface-file-inventory.paths'); const mdFile = join(root, 'docs/astryx-surface-file-inventory.md'); +// One pre-existing reasoning disclosure owns button semantics on a div. Keep +// its full diagnostic as the baseline: another control, even in the same file, +// changes the occurrence count and fails admission. +const legacyBlockerBaseline = new Map([ + [ + 'packages/ui/src/astryx-chat-reasoning.tsx', + 'hand-written interactive `
` (1 occurrence); use Astryx `Button`; do not hand-write controls from raw elements or custom control CSS (API Use-the-System)', + ], +]); function main() { if (!existsSync(pathsFile) || !existsSync(mdFile)) { @@ -44,6 +56,7 @@ function main() { } const rendered = renderAstryxSurfaceInventory(root); + assertNoAstryxBlockers(rendered, legacyBlockerBaseline); const committedMd = readFileSync(mdFile, 'utf8'); const committedPaths = readFileSync(pathsFile, 'utf8'); const mdDrift = committedMd !== rendered.markdown; diff --git a/scripts/generate-astryx-surface-inventory.mjs b/scripts/generate-astryx-surface-inventory.mjs index 076e7753bd..9b4c130595 100644 --- a/scripts/generate-astryx-surface-inventory.mjs +++ b/scripts/generate-astryx-surface-inventory.mjs @@ -385,6 +385,8 @@ const RAW_BUTTON_RE = /<\s*button\b/; const RAW_INPUT_RE = /<\s*input\b/; const RAW_SELECT_RE = /<\s*select\b/; const RAW_TEXTAREA_RE = /<\s*textarea\b/; +const RAW_ACTION_ELEMENT_RE = + /<\s*(span|div)\b(?=[^>]*(?:onClick\s*=|role\s*=\s*['"]button['"]))[^>]*>/g; const HEX_RE = /#[0-9a-fA-F]{3,8}\b/; const OFF_HEIGHT_RE = /(?:min-)?height:\s*(\d+)px/g; const ALLOWED_H = new Set([28, 32, 36]); @@ -528,11 +530,22 @@ export function analyzeTsx(rel, text, ctx) { const rawInput = RAW_INPUT_RE.test(code); const rawSelect = RAW_SELECT_RE.test(code); const rawTextarea = RAW_TEXTAREA_RE.test(code); + const rawActionElements = [...code.matchAll(RAW_ACTION_ELEMENT_RE)].map((match) => match[1]); + const rawActionElementCounts = new Map(); + for (const element of rawActionElements) { + rawActionElementCounts.set(element, (rawActionElementCounts.get(element) || 0) + 1); + } const gaps = []; + const admissionGaps = []; if (rawButton) gaps.push('raw `\` (${count} ${count === 1 ? 'occurrence' : 'occurrences'}); use Astryx \`Button\`; do not hand-write controls from raw elements or custom control CSS (API Use-the-System)`, + ); + } for (const name of shadows) { gaps.push(`public export \`${name}\` shadows Astryx component (not a re-export)`); } @@ -552,6 +565,7 @@ export function analyzeTsx(rel, text, ctx) { : 'aligned — no raw controls; no Astryx JSX usage'; return { astryx: named.size > 0 ? [...named].sort().join(', ') : 'none', + admissionGaps, gaps: note, severity, }; @@ -696,10 +710,25 @@ export function renderAstryxSurfaceInventory(repoRoot = root) { files, excluded, totals: bySev, + blockers: rows.flatMap((row) => [ + ...(row.severity === 'blocker' ? [{ path: row.path, gaps: row.gaps }] : []), + ...(row.admissionGaps || []).map((gaps) => ({ path: row.path, gaps })), + ]), version, }; } +export function assertNoAstryxBlockers(rendered, legacyBaseline = new Map()) { + const blockers = rendered.blockers.filter( + (blocker) => legacyBaseline.get(blocker.path) !== blocker.gaps, + ); + if (blockers.length === 0) return; + const details = blockers.map((blocker) => `- ${blocker.path}: ${blocker.gaps}`).join('\n'); + throw new Error( + `Astryx surface blocker: hand-written controls are not allowed when Astryx owns the component. Use Astryx \`Button\` or the matching Astryx primitive; do not hand-write controls from raw elements or custom control CSS.\n${details}`, + ); +} + function main() { const rendered = renderAstryxSurfaceInventory(root); const mdPath = join(root, 'docs/astryx-surface-file-inventory.md'); diff --git a/scripts/generate-astryx-surface-inventory.test.mjs b/scripts/generate-astryx-surface-inventory.test.mjs index d92f104be3..db55428022 100644 --- a/scripts/generate-astryx-surface-inventory.test.mjs +++ b/scripts/generate-astryx-surface-inventory.test.mjs @@ -25,6 +25,7 @@ import { after, describe, test } from 'node:test'; import { analyzeTsx, + assertNoAstryxBlockers, assertAstryxComponentSet, loadAstryxComponents, loadMakaUiBarrel, @@ -225,4 +226,61 @@ describe('analyzeTsx severity (#3868)', () => { const result = analyzeTsx('packages/ui/src/bad.tsx', src, base); assert.equal(result.severity, 'blocker'); }); + + test('blocks hand-written span and div actions with an actionable Astryx diagnostic', () => { + for (const src of [ + 'export function Bad() { return {}}>x; }', + 'export function Bad() { return
x
; }', + ]) { + const result = analyzeTsx('packages/ui/src/bad.tsx', src, base); + assert.equal(result.severity, 'aligned'); + assert.match(result.admissionGaps.join('; '), /use Astryx `Button`/); + assert.match(result.admissionGaps.join('; '), /do not hand-write controls/); + } + }); + + test('allows non-interactive spans and divs used for layout or text', () => { + const src = 'export function Fine() { return
x
; }\n'; + const result = analyzeTsx('packages/ui/src/fine.tsx', src, base); + assert.equal(result.severity, 'aligned'); + assert.deepEqual(result.admissionGaps, []); + }); + + test('fails the CI gate even after a blocker inventory is regenerated', () => { + assert.throws( + () => + assertNoAstryxBlockers({ + blockers: [ + { + path: 'packages/ui/src/bad.tsx', + gaps: 'hand-written interactive ``; use Astryx `Button`; do not hand-write controls', + }, + ], + }), + /Astryx surface blocker.*packages\/ui\/src\/bad\.tsx.*use Astryx `Button`.*do not hand-write controls/is, + ); + }); + + test('permits only exact legacy blocker fingerprints, never new debt at the same path', () => { + const path = 'packages/ui/src/legacy.tsx'; + const gaps = + 'hand-written interactive `
` (1 occurrence); use Astryx `Button`; do not hand-write controls'; + const baseline = new Map([[path, gaps]]); + assert.doesNotThrow(() => assertNoAstryxBlockers({ blockers: [{ path, gaps }] }, baseline)); + assert.throws( + () => + assertNoAstryxBlockers( + { + blockers: [ + { + path, + gaps: 'hand-written interactive `
` (2 occurrences); use Astryx `Button`; do not hand-write controls', + }, + ], + }, + baseline, + ), + /Astryx surface blocker/, + ); + }); }); From 6904f73a8fc10f97f4d8508ab7b66adba927e0b3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 03:49:24 +0800 Subject: [PATCH 3/6] chore(desktop): update renderer architecture ledger Generated-by: Codex --- apps/desktop/renderer-architecture.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d23898ba63..d7dc1eef35 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "nonTriviaTokens": 15704 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, From c2450d740a82b511784702e47259cc984d536141 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 04:00:23 +0800 Subject: [PATCH 4/6] fix(desktop): satisfy renderer architecture ratchet Generated-by: Codex --- apps/desktop/renderer-architecture.json | 2 +- apps/desktop/src/renderer/app-shell.tsx | 22 ++++++++-------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d7dc1eef35..d23898ba63 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15704 + "nonTriviaTokens": 15687 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 42cdc18b6b..7695955483 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -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 @@ -2984,7 +2984,7 @@ function AppShellContent({ ? undefined : attachFilePaths } - modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} + modelLabel={activeModelLabel ?? newChatModelLabel} activeSession={activeSessionForView} activeModelConnectionId={activeSessionForModelControls?.llmConnectionId} activeModelConnectionSlug={activeSessionForModelControls?.llmConnectionSlug} @@ -2992,15 +2992,13 @@ function AppShellContent({ activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} - onOpenContextUsage={() => workbar.commands.openTool('inspector', 'right')} + onOpenContextUsage={() => workbar.commands.openTool('inspector')} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} renderProviderMark={(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) => { @@ -3045,8 +3043,8 @@ function AppShellContent({ } onPermissionModeChange={ activeBoundarySurface.localInteractionAvailable - ? async (mode) => { - await setPermissionMode(mode); + ? async mode => { + await setPermissionMode(mode) } : undefined } @@ -3056,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 From 19cd49008cb8a623862e928db619209ad6a85010 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 09:17:53 +0800 Subject: [PATCH 5/6] fix(ui): clarify context usage tooltip Generated-by: Codex --- .../__tests__/composer-context-usage.test.tsx | 1 + .../src/__tests__/conversation-copy.test.ts | 33 +++++++++++++++++++ packages/ui/src/composer.tsx | 10 +++--- packages/ui/src/conversation-copy.ts | 19 +++++++---- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 939489ae3b..b066f9c5dc 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -68,6 +68,7 @@ test('the context usage action opens its host trace surface', async () => { 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); diff --git a/packages/ui/src/__tests__/conversation-copy.test.ts b/packages/ui/src/__tests__/conversation-copy.test.ts index 9ddfe08ad2..d3b9eb2079 100644 --- a/packages/ui/src/__tests__/conversation-copy.test.ts +++ b/packages/ui/src/__tests__/conversation-copy.test.ts @@ -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 diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 9f2e88f897..9ba7b50206 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -2258,17 +2258,15 @@ function ContextUsageAction(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; + : copy.systemNotes.contextUsageNoWindow(props.usageTokens); return ( 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; @@ -545,10 +546,12 @@ 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: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, @@ -720,10 +723,12 @@ 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.', }, From 8f0705901de2a0b3166e61e99f656ebdee73328b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 4 Sep 2026 09:27:22 +0800 Subject: [PATCH 6/6] fix(ui): shorten context usage tooltips Generated-by: Codex --- packages/ui/src/__tests__/conversation-copy.test.ts | 6 +++--- packages/ui/src/conversation-copy.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/__tests__/conversation-copy.test.ts b/packages/ui/src/__tests__/conversation-copy.test.ts index d3b9eb2079..f1d9c37e8f 100644 --- a/packages/ui/src/__tests__/conversation-copy.test.ts +++ b/packages/ui/src/__tests__/conversation-copy.test.ts @@ -39,7 +39,7 @@ 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, @@ -50,7 +50,7 @@ test('context usage explains missing data without exposing provider internals', 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%)。', + '已用 12,345 / 128,000 token(10%)', ); assert.equal( getConversationCopy('en').messages.systemNotes.contextUsageShare(12_345, 128_000), @@ -61,7 +61,7 @@ test('context usage tooltip leads with the measured share', () => { test('context usage tooltip keeps measured usage when the limit is unknown', () => { assert.equal( getConversationCopy('zh').messages.systemNotes.contextUsageNoWindow(12_345), - '本次请求已使用 12,345 个 token;当前模型没有可用的上下文上限。', + '已用 12,345 token;上下文上限未知', ); assert.equal( getConversationCopy('en').messages.systemNotes.contextUsageNoWindow(12_345), diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 8e558d964f..03200dd551 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -548,10 +548,10 @@ const CONVERSATION_COPY = { '已经压缩过历史,供应商仍然说这次请求太大。剩下的部分还包含系统提示、工具定义、摘要和最近的原文,缩短这条消息是你能控制的那一半。', contextUsageLabel: '用量', contextUsageShare: (used, window) => - `本次请求已使用 ${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} 个 token(${Math.round((used / window) * 100)}%)。`, + `已用 ${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} token(${Math.round((used / window) * 100)}%)`, contextUsageNoWindow: (used) => - `本次请求已使用 ${used.toLocaleString('zh-CN')} 个 token;当前模型没有可用的上下文上限。`, - contextUsageUnavailable: '本次请求暂无可用的用量数据。', + `已用 ${used.toLocaleString('zh-CN')} token;上下文上限未知`, + contextUsageUnavailable: '暂无用量数据', contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', },